From 5f916cecbad23c5be3459a7b6c70212d31867fc5 Mon Sep 17 00:00:00 2001 From: Arthur M <4rthem@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:42:29 +0100 Subject: [PATCH] PS-706 discussions (#508) * prevent rendering App component if dialog is opened * fix color picker --- dashboard/client/package.json | 1 + databox/api/composer.json | 8 + databox/api/composer.lock | 525 ++++++++++-------- databox/api/config/bundles.php | 1 + .../packages/arthem_object_reference.yaml | 1 + databox/api/config/packages/fos_elastica.yaml | 29 + .../api/migrations/Version20241230150924.php | 45 ++ .../api/migrations/Version20250106104251.php | 34 ++ .../api/migrations/Version20250106174830.php | 32 ++ .../Model/Input/EditThreadMessageInput.php | 13 + .../Api/Model/Input/ThreadMessageInput.php | 27 + .../api/src/Api/Model/Output/AssetOutput.php | 7 + .../Model/Output/ESDocumentStateOutput.php | 3 +- .../Api/Model/Output/ThreadMessageOutput.php | 40 ++ .../AssetOutputTransformer.php | 7 + .../ThreadMessageOutputTransformer.php | 58 ++ .../Api/Processor/PostMessageProcessor.php | 72 +++ .../src/Api/Processor/PutMessageProcessor.php | 47 ++ .../Api/Provider/ThreadMessagesProvider.php | 41 ++ .../Command/DocumentationDumperCommand.php | 8 +- .../Discussion/PostDiscussionMessage.php | 19 + .../PostDiscussionMessageHandler.php | 46 ++ .../Admin/JobStateCrudController.php | 1 - .../src/Doctrine/Listener/FileListener.php | 2 - .../Listener/ThreadMessageListener.php | 41 ++ .../Elasticsearch/ESDocumentStateManager.php | 3 +- .../Listener/MessagePostTransformListener.php | 35 ++ databox/api/src/Entity/Basket/Basket.php | 4 +- databox/api/src/Entity/Basket/BasketAsset.php | 2 +- databox/api/src/Entity/Core/Asset.php | 14 +- .../api/src/Entity/Core/AssetFileVersion.php | 4 +- .../api/src/Entity/Core/AssetRelationship.php | 2 +- .../api/src/Entity/Core/AssetRendition.php | 5 +- .../api/src/Entity/Core/AttributeClass.php | 2 +- .../src/Entity/Core/AttributeDefinition.php | 4 +- .../api/src/Entity/Core/AttributeEntity.php | 4 +- databox/api/src/Entity/Core/Collection.php | 12 +- .../api/src/Entity/Core/CollectionAsset.php | 2 +- databox/api/src/Entity/Core/File.php | 4 +- .../api/src/Entity/Core/RenditionClass.php | 4 +- databox/api/src/Entity/Core/RenditionRule.php | 4 +- databox/api/src/Entity/Core/Share.php | 4 +- databox/api/src/Entity/Core/Tag.php | 4 +- databox/api/src/Entity/Core/TagFilterRule.php | 4 +- databox/api/src/Entity/Core/Workspace.php | 4 +- databox/api/src/Entity/Discussion/Message.php | 148 +++++ databox/api/src/Entity/Discussion/Thread.php | 49 ++ .../Entity/Integration/IntegrationData.php | 4 +- .../Entity/Integration/IntegrationToken.php | 2 +- .../src/Entity/Integration/WorkspaceEnv.php | 4 +- .../Integration/WorkspaceIntegration.php | 4 +- .../Entity/Integration/WorkspaceSecret.php | 4 +- .../api/src/Entity/ObjectTitleInterface.php | 8 + .../src/Entity/Template/AssetDataTemplate.php | 4 +- .../Traits/AssetAnnotationsInterface.php | 2 +- .../src/Entity/Traits/NovuTopicKeyTrait.php | 27 + .../Fixture/Faker/AssetAnnotationsFaker.php | 3 + .../Phrasea/Expose/ExposeClient.php | 46 +- .../Listener/AssetIngestWorkflowListener.php | 9 +- .../Discussion/MessageRepository.php | 39 ++ .../Discussion/ThreadRepository.php | 27 + .../src/Security/Voter/ThreadMessageVoter.php | 38 ++ .../api/src/Security/Voter/ThreadVoter.php | 46 ++ databox/api/src/Service/DiscussionManager.php | 57 ++ databox/api/src/Service/DiscussionPusher.php | 36 ++ databox/api/src/Storage/RenditionManager.php | 5 +- ...RenditionDefinitionConstraintValidator.php | 4 +- databox/api/symfony.lock | 3 + databox/client/config-compiler.js | 4 +- databox/client/package.json | 5 + databox/client/src/api/asset.ts | 25 +- databox/client/src/api/basket.ts | 3 +- databox/client/src/api/discussion.ts | 38 ++ databox/client/src/api/hydra.ts | 8 +- databox/client/src/api/uploader/file.ts | 2 +- databox/client/src/components/App.tsx | 11 +- .../components/AssetSearch/AssetSearch.tsx | 24 +- .../components/Dialog/Asset/AssetDialog.tsx | 2 +- .../Dialog/Asset/AssetFileVersion.tsx | 2 +- .../Dialog/Asset/AssetFileVersions.tsx | 9 +- .../components/Dialog/Asset/ESDocument.tsx | 84 +-- .../src/components/Dialog/Asset/InfoAsset.tsx | 23 +- .../Dialog/Asset/OperationsAsset.tsx | 4 +- .../src/components/Dialog/Asset/Rendition.tsx | 95 +++- .../Dialog/Asset/RenditionSkeleton.tsx | 16 +- .../Dialog/Asset/RenditionStructure.tsx | 20 +- .../components/Dialog/Asset/Renditions.tsx | 12 +- .../components/Dialog/Basket/Integrations.tsx | 2 +- .../Dialog/Collection/CollectionDialog.tsx | 2 +- .../Dialog/Collection/InfoCollection.tsx | 4 +- .../src/components/Dialog/Info/InfoRow.tsx | 25 +- .../src/components/Dialog/Workspace/Acl.tsx | 6 +- .../src/components/Discussion/Attachments.tsx | 42 ++ .../Discussion/DiscussionMessage.tsx | 171 ++++++ .../src/components/Discussion/EditMessage.tsx | 56 ++ .../src/components/Discussion/EmojiPicker.tsx | 55 ++ .../components/Discussion/MessageField.tsx | 134 +++++ .../src/components/Discussion/MessageForm.tsx | 146 +++++ .../src/components/Discussion/Thread.tsx | 171 ++++++ .../AwsRekognitionAssetEditorActions.tsx | 2 +- .../Integration/Phrasea/Expose/exposeType.ts | 2 +- .../RemoveBG/RemoveBGAssetEditorActions.tsx | 2 +- .../TuiPhotoEditor/TUIPhotoEditor.tsx | 2 +- .../src/components/Integration/types.ts | 2 +- .../src/components/Layout/MainAppBar.tsx | 41 +- .../Media/Asset/Actions/AssetViewActions.tsx | 129 +++-- .../Asset/Actions/SubstituteFileDialog.tsx | 45 -- .../Asset/Annotations/AnnotateToolbar.tsx | 134 +++++ .../Asset/Annotations/AnnotateWrapper.tsx | 76 +++ .../Annotations/AssetAnnotationsOverlay.tsx | 108 ++-- .../Asset/Annotations/CircleAnnotation.tsx | 34 -- .../Annotations/CircleAnnotationHandler.ts | 93 ++++ .../Annotations/DrawAnnotationHandler.ts | 137 +++++ .../Annotations/HighlightAnnotationHandler.ts | 14 + .../Asset/Annotations/PointAnnotation.tsx | 24 - .../Annotations/PointAnnotationHandler.ts | 69 +++ .../Asset/Annotations/RectAnnotation.tsx | 34 -- .../Annotations/RectAnnotationHandler.ts | 112 ++++ .../Asset/Annotations/annotationTypes.ts | 79 +++ .../Media/Asset/Annotations/events.ts | 77 +++ .../Asset/Annotations/useAnnotationDraw.ts | 210 +++++++ .../Media/Asset/AssetAttributes.tsx | 43 +- .../Media/Asset/AssetDiscussion.tsx | 54 ++ .../src/components/Media/Asset/AssetView.tsx | 259 --------- .../Media/Asset/AssetViewNavigation.tsx | 55 ++ .../Media/Asset/Attribute/AttributeRowUI.tsx | 34 +- .../Media/Asset/Attribute/Attributes.tsx | 11 +- .../Media/Asset/Attribute/BatchActions.ts | 6 +- .../Media/Asset/FileIntegrations.tsx | 48 +- .../src/components/Media/Asset/FilePlayer.tsx | 81 +-- .../Media/Asset/Players/FileToolbar.tsx | 167 ++++++ .../Media/Asset/Players/ImagePlayer.tsx | 61 ++ .../Media/Asset/Players/PDFPlayer.tsx | 239 ++++---- .../Media/Asset/Players/ToolbarPaper.tsx | 35 ++ .../Media/Asset/Players/ZoomControls.tsx | 51 ++ .../components/Media/Asset/Players/index.ts | 11 + .../components/Media/Asset/View/AssetView.tsx | 243 ++++++++ .../Media/Asset/View/AssetViewHeader.tsx | 71 +++ .../src/components/Media/Asset/assetTypes.ts | 3 + .../Media/Search/ResultProvider.tsx | 2 +- .../components/Share/CreateShareDialog.tsx | 2 +- .../client/src/components/Share/ShareItem.tsx | 2 +- .../src/components/Share/UrlActions.tsx | 2 +- .../client/src/components/Ui/PrivacyField.tsx | 156 +++--- .../client/src/components/Upload/FileCard.tsx | 22 +- databox/client/src/hooks/useAssetActions.ts | 3 +- databox/client/src/routes.ts | 2 +- databox/client/src/types.ts | 58 +- expose/client/package.json | 1 + lib/js/navigation/src/types.ts | 9 +- lib/js/phrasea-ui/index.ts | 4 + .../src/components/Dialog/AppDialog.tsx | 9 +- lib/js/phrasea-ui/src/components/FlexRow.tsx | 8 +- .../src/components/MoreActionsButton.tsx | 44 ++ .../phrasea-ui/src/components/UserAvatar.tsx | 66 +++ lib/js/phrasea-ui/src/components/UserMenu.tsx | 19 +- lib/js/react-form/src/Color/ColorPicker.tsx | 77 ++- lib/js/react-hooks/src/useWindowSize.ts | 4 +- lib/php/admin-bundle/composer.json | 2 +- .../notify-bundle/src/AlchemyNotifyBundle.php | 32 +- .../src/Notification/NotifierInterface.php | 17 + .../src/Notification/SymfonyNotifier.php | 21 + .../notify-bundle/src/Service/NovuClient.php | 67 +++ novu/bridge/app/api/novu/route.ts | 2 + .../databox/databoxDiscussionNewComment.tsx | 24 + .../app/novu/workflows/databox/index.ts | 1 + novu/bridge/app/novu/workflows/index.ts | 1 + pnpm-lock.yaml | 49 +- uploader/client/package.json | 1 + 169 files changed, 5184 insertions(+), 1420 deletions(-) create mode 100644 databox/api/migrations/Version20241230150924.php create mode 100644 databox/api/migrations/Version20250106104251.php create mode 100644 databox/api/migrations/Version20250106174830.php create mode 100644 databox/api/src/Api/Model/Input/EditThreadMessageInput.php create mode 100644 databox/api/src/Api/Model/Input/ThreadMessageInput.php create mode 100644 databox/api/src/Api/Model/Output/ThreadMessageOutput.php create mode 100644 databox/api/src/Api/OutputTransformer/ThreadMessageOutputTransformer.php create mode 100644 databox/api/src/Api/Processor/PostMessageProcessor.php create mode 100644 databox/api/src/Api/Processor/PutMessageProcessor.php create mode 100644 databox/api/src/Api/Provider/ThreadMessagesProvider.php create mode 100644 databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessage.php create mode 100644 databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessageHandler.php create mode 100644 databox/api/src/Doctrine/Listener/ThreadMessageListener.php create mode 100644 databox/api/src/Elasticsearch/Listener/MessagePostTransformListener.php create mode 100644 databox/api/src/Entity/Discussion/Message.php create mode 100644 databox/api/src/Entity/Discussion/Thread.php create mode 100644 databox/api/src/Entity/ObjectTitleInterface.php create mode 100644 databox/api/src/Entity/Traits/NovuTopicKeyTrait.php create mode 100644 databox/api/src/Repository/Discussion/MessageRepository.php create mode 100644 databox/api/src/Repository/Discussion/ThreadRepository.php create mode 100644 databox/api/src/Security/Voter/ThreadMessageVoter.php create mode 100644 databox/api/src/Security/Voter/ThreadVoter.php create mode 100644 databox/api/src/Service/DiscussionManager.php create mode 100644 databox/api/src/Service/DiscussionPusher.php create mode 100644 databox/client/src/api/discussion.ts create mode 100644 databox/client/src/components/Discussion/Attachments.tsx create mode 100644 databox/client/src/components/Discussion/DiscussionMessage.tsx create mode 100644 databox/client/src/components/Discussion/EditMessage.tsx create mode 100644 databox/client/src/components/Discussion/EmojiPicker.tsx create mode 100644 databox/client/src/components/Discussion/MessageField.tsx create mode 100644 databox/client/src/components/Discussion/MessageForm.tsx create mode 100644 databox/client/src/components/Discussion/Thread.tsx delete mode 100644 databox/client/src/components/Media/Asset/Actions/SubstituteFileDialog.tsx create mode 100644 databox/client/src/components/Media/Asset/Annotations/AnnotateToolbar.tsx create mode 100644 databox/client/src/components/Media/Asset/Annotations/AnnotateWrapper.tsx delete mode 100644 databox/client/src/components/Media/Asset/Annotations/CircleAnnotation.tsx create mode 100644 databox/client/src/components/Media/Asset/Annotations/CircleAnnotationHandler.ts create mode 100644 databox/client/src/components/Media/Asset/Annotations/DrawAnnotationHandler.ts create mode 100644 databox/client/src/components/Media/Asset/Annotations/HighlightAnnotationHandler.ts delete mode 100644 databox/client/src/components/Media/Asset/Annotations/PointAnnotation.tsx create mode 100644 databox/client/src/components/Media/Asset/Annotations/PointAnnotationHandler.ts delete mode 100644 databox/client/src/components/Media/Asset/Annotations/RectAnnotation.tsx create mode 100644 databox/client/src/components/Media/Asset/Annotations/RectAnnotationHandler.ts create mode 100644 databox/client/src/components/Media/Asset/Annotations/annotationTypes.ts create mode 100644 databox/client/src/components/Media/Asset/Annotations/events.ts create mode 100644 databox/client/src/components/Media/Asset/Annotations/useAnnotationDraw.ts create mode 100644 databox/client/src/components/Media/Asset/AssetDiscussion.tsx delete mode 100644 databox/client/src/components/Media/Asset/AssetView.tsx create mode 100644 databox/client/src/components/Media/Asset/AssetViewNavigation.tsx create mode 100644 databox/client/src/components/Media/Asset/Players/FileToolbar.tsx create mode 100644 databox/client/src/components/Media/Asset/Players/ImagePlayer.tsx create mode 100644 databox/client/src/components/Media/Asset/Players/ToolbarPaper.tsx create mode 100644 databox/client/src/components/Media/Asset/Players/ZoomControls.tsx create mode 100644 databox/client/src/components/Media/Asset/View/AssetView.tsx create mode 100644 databox/client/src/components/Media/Asset/View/AssetViewHeader.tsx create mode 100644 databox/client/src/components/Media/Asset/assetTypes.ts create mode 100644 lib/js/phrasea-ui/src/components/MoreActionsButton.tsx create mode 100644 lib/js/phrasea-ui/src/components/UserAvatar.tsx create mode 100644 lib/php/notify-bundle/src/Service/NovuClient.php create mode 100644 novu/bridge/app/novu/workflows/databox/databoxDiscussionNewComment.tsx create mode 100644 novu/bridge/app/novu/workflows/databox/index.ts diff --git a/dashboard/client/package.json b/dashboard/client/package.json index 9dc5931d9..dd56d4550 100644 --- a/dashboard/client/package.json +++ b/dashboard/client/package.json @@ -8,6 +8,7 @@ "lint": "eslint src", "lint:fix": "eslint --fix src", "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json|cjs|tsx|jsx)\"", + "cs": "pnpm lint:fix && pnpm format", "preview": "vite preview" }, "dependencies": { diff --git a/databox/api/composer.json b/databox/api/composer.json index 19276df3a..f86e01b48 100644 --- a/databox/api/composer.json +++ b/databox/api/composer.json @@ -29,6 +29,13 @@ "symlink": true } }, + { + "type": "path", + "url": "../../lib/php/notify-bundle", + "options": { + "symlink": true + } + }, { "type": "path", "url": "../../lib/php/test-bundle", @@ -140,6 +147,7 @@ "alchemy/auth-bundle": "*", "alchemy/configurator-bundle": "@dev", "alchemy/core-bundle": "@dev", + "alchemy/notify-bundle": "@dev", "alchemy/es-bundle": "@dev", "alchemy/messenger-bundle": "@dev", "alchemy/metadata-manipulator-bundle": "@dev", diff --git a/databox/api/composer.lock b/databox/api/composer.lock index 20a57a83b..3bfa2b873 100644 --- a/databox/api/composer.lock +++ b/databox/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2d58399fb22f00320aa0d0e5dd2f061b", + "content-hash": "054c705fd0773160c3f235191a1751f0", "packages": [ { "name": "alchemy/acl-bundle", @@ -59,15 +59,15 @@ }, { "name": "alchemy/admin-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/admin-bundle", - "reference": "0be6457db8dab79c06368f722243dc1314662d47" + "reference": "1f25e2c37d21d94a8e0b19d1f342a17981f902a8" }, "require": { "alchemy/auth-bundle": "@dev", - "easycorp/easyadmin-bundle": "^4.0", + "easycorp/easyadmin-bundle": "^4.0,<=4.20.2", "guzzlehttp/guzzle": "^7.2", "php": "^8.3", "symfony/framework-bundle": "^6" @@ -117,7 +117,7 @@ }, { "name": "alchemy/auth-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/auth-bundle", @@ -174,7 +174,7 @@ }, { "name": "alchemy/configurator-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/configurator-bundle", @@ -227,7 +227,7 @@ }, { "name": "alchemy/core-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/core-bundle", @@ -288,7 +288,7 @@ }, { "name": "alchemy/es-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/es-bundle", @@ -344,7 +344,7 @@ }, { "name": "alchemy/messenger-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/messenger-bundle", @@ -397,7 +397,7 @@ }, { "name": "alchemy/metadata-manipulator-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/metadata-manipulator-bundle", @@ -450,6 +450,61 @@ "relative": true } }, + { + "name": "alchemy/notify-bundle", + "version": "dev-PS-706-discussions", + "dist": { + "type": "path", + "url": "../../lib/php/notify-bundle", + "reference": "526e45c09e65c5ca6ad1890c957dac757c74f04f" + }, + "require": { + "php": "^8.3", + "symfony/framework-bundle": "^6.4", + "symfony/notifier": "^6.4.13" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.17", + "rector/rector": "^1.0.4" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Alchemy\\NotifyBundle\\": "src" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "autoload-dev": { + "psr-4": { + "Alchemy\\NotifyBundle\\Tests\\": "tests" + } + }, + "scripts": { + "rector": [ + "vendor/bin/rector" + ], + "cs": [ + "vendor/bin/php-cs-fixer fix" + ], + "test": [ + "echo 'This project has no test...'" + ] + }, + "license": [ + "MIT" + ], + "description": "Symfony notify bundle", + "homepage": "https://www.alchemy.fr/", + "keywords": [ + "Notification email sms" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "alchemy/phpexiftool", "version": "4.1.1", @@ -520,7 +575,7 @@ }, { "name": "alchemy/rendition-factory", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/rendition-factory", @@ -582,7 +637,7 @@ }, { "name": "alchemy/rendition-factory-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/rendition-factory-bundle", @@ -636,7 +691,7 @@ }, { "name": "alchemy/storage-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/storage-bundle", @@ -691,7 +746,7 @@ }, { "name": "alchemy/test-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/test-bundle", @@ -743,7 +798,7 @@ }, { "name": "alchemy/webhook-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/webhook-bundle", @@ -800,7 +855,7 @@ }, { "name": "alchemy/workflow", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/workflow", @@ -865,7 +920,7 @@ }, { "name": "alchemy/workflow-bundle", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/workflow-bundle", @@ -921,16 +976,16 @@ }, { "name": "api-platform/core", - "version": "v3.4.8", + "version": "v3.4.10", "source": { "type": "git", "url": "https://github.com/api-platform/core.git", - "reference": "985a9a0408cfc31721adbe43c6ae38d9c3a8c88f" + "reference": "f8dae8e1154480a49e86d2393118ffbd99acc51c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/api-platform/core/zipball/985a9a0408cfc31721adbe43c6ae38d9c3a8c88f", - "reference": "985a9a0408cfc31721adbe43c6ae38d9c3a8c88f", + "url": "https://api.github.com/repos/api-platform/core/zipball/f8dae8e1154480a49e86d2393118ffbd99acc51c", + "reference": "f8dae8e1154480a49e86d2393118ffbd99acc51c", "shasum": "" }, "require": { @@ -1136,9 +1191,9 @@ ], "support": { "issues": "https://github.com/api-platform/core/issues", - "source": "https://github.com/api-platform/core/tree/v3.4.8" + "source": "https://github.com/api-platform/core/tree/v3.4.10" }, - "time": "2024-12-06T11:11:33+00:00" + "time": "2024-12-20T10:18:28+00:00" }, { "name": "arthem/object-reference-bundle", @@ -1241,16 +1296,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.334.2", + "version": "3.336.8", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "b19afc076bb1cc2617bdef76efd41587596109e7" + "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b19afc076bb1cc2617bdef76efd41587596109e7", - "reference": "b19afc076bb1cc2617bdef76efd41587596109e7", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", + "reference": "933da0d1b9b1ac9b37d5e32e127d4581b1aabaf6", "shasum": "" }, "require": { @@ -1333,9 +1388,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.334.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.8" }, - "time": "2024-12-09T19:30:23+00:00" + "time": "2025-01-03T19:06:11+00:00" }, { "name": "beberlei/doctrineextensions", @@ -2235,20 +2290,20 @@ }, { "name": "doctrine/common", - "version": "3.4.5", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/common.git", - "reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286" + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/common/zipball/6c8fef961f67b8bc802ce3e32e3ebd1022907286", - "reference": "6c8fef961f67b8bc802ce3e32e3ebd1022907286", + "url": "https://api.github.com/repos/doctrine/common/zipball/d9ea4a54ca2586db781f0265d36bea731ac66ec5", + "reference": "d9ea4a54ca2586db781f0265d36bea731ac66ec5", "shasum": "" }, "require": { - "doctrine/persistence": "^2.0 || ^3.0", + "doctrine/persistence": "^2.0 || ^3.0 || ^4.0", "php": "^7.1 || ^8.0" }, "require-dev": { @@ -2306,7 +2361,7 @@ ], "support": { "issues": "https://github.com/doctrine/common/issues", - "source": "https://github.com/doctrine/common/tree/3.4.5" + "source": "https://github.com/doctrine/common/tree/3.5.0" }, "funding": [ { @@ -2322,7 +2377,7 @@ "type": "tidelift" } ], - "time": "2024-10-08T15:53:43+00:00" + "time": "2025-01-01T22:12:03+00:00" }, { "name": "doctrine/data-fixtures", @@ -3214,16 +3269,16 @@ }, { "name": "doctrine/orm", - "version": "2.20.0", + "version": "2.20.1", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c" + "reference": "e3cabade99ebccc6ba078884c1c5f250866a494e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/8ed6c2234aba019f9737a6bcc9516438e62da27c", - "reference": "8ed6c2234aba019f9737a6bcc9516438e62da27c", + "url": "https://api.github.com/repos/doctrine/orm/zipball/e3cabade99ebccc6ba078884c1c5f250866a494e", + "reference": "e3cabade99ebccc6ba078884c1c5f250866a494e", "shasum": "" }, "require": { @@ -3253,15 +3308,14 @@ "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", "phpstan/extension-installer": "~1.1.0 || ^1.4", - "phpstan/phpstan": "~1.4.10 || 1.12.6", - "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan": "~1.4.10 || 2.0.3", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^4.4 || ^5.4 || ^6.4 || ^7.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2 || ^7.0", - "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "vimeo/psalm": "4.30.0 || 5.24.0" + "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0 || ^7.0" }, "suggest": { "ext-dom": "Provides support for XSD validation for XML mapping files", @@ -3311,9 +3365,9 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/2.20.0" + "source": "https://github.com/doctrine/orm/tree/2.20.1" }, - "time": "2024-10-11T11:47:24+00:00" + "time": "2024-12-19T06:48:36+00:00" }, { "name": "doctrine/persistence", @@ -5307,16 +5361,16 @@ }, { "name": "liip/imagine-bundle", - "version": "2.13.2", + "version": "2.13.3", "source": { "type": "git", "url": "https://github.com/liip/LiipImagineBundle.git", - "reference": "98e0318ea0f7b9500343236e63cc29ded58a1d43" + "reference": "3faccde327f91368e51d05ecad49a9cd915abd81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/98e0318ea0f7b9500343236e63cc29ded58a1d43", - "reference": "98e0318ea0f7b9500343236e63cc29ded58a1d43", + "url": "https://api.github.com/repos/liip/LiipImagineBundle/zipball/3faccde327f91368e51d05ecad49a9cd915abd81", + "reference": "3faccde327f91368e51d05ecad49a9cd915abd81", "shasum": "" }, "require": { @@ -5407,9 +5461,9 @@ ], "support": { "issues": "https://github.com/liip/LiipImagineBundle/issues", - "source": "https://github.com/liip/LiipImagineBundle/tree/2.13.2" + "source": "https://github.com/liip/LiipImagineBundle/tree/2.13.3" }, - "time": "2024-09-04T12:55:26+00:00" + "time": "2024-12-12T09:38:23+00:00" }, { "name": "masterminds/html5", @@ -5709,16 +5763,16 @@ }, { "name": "nelmio/alice", - "version": "3.13.6", + "version": "3.14.0", "source": { "type": "git", "url": "https://github.com/nelmio/alice.git", - "reference": "76caab8675c68956d56a2dd03f66384251e0aa7c" + "reference": "40b240dc33ceee636bad1da9ea2a87a3add3bf7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/alice/zipball/76caab8675c68956d56a2dd03f66384251e0aa7c", - "reference": "76caab8675c68956d56a2dd03f66384251e0aa7c", + "url": "https://api.github.com/repos/nelmio/alice/zipball/40b240dc33ceee636bad1da9ea2a87a3add3bf7a", + "reference": "40b240dc33ceee636bad1da9ea2a87a3add3bf7a", "shasum": "" }, "require": { @@ -5792,7 +5846,7 @@ ], "support": { "issues": "https://github.com/nelmio/alice/issues", - "source": "https://github.com/nelmio/alice/tree/3.13.6" + "source": "https://github.com/nelmio/alice/tree/3.14.0" }, "funding": [ { @@ -5800,7 +5854,7 @@ "type": "github" } ], - "time": "2024-07-03T17:54:12+00:00" + "time": "2024-12-23T11:09:53+00:00" }, { "name": "nelmio/cors-bundle", @@ -6016,16 +6070,16 @@ }, { "name": "pagerfanta/pagerfanta", - "version": "v4.7.0", + "version": "v4.7.1", "source": { "type": "git", "url": "https://github.com/BabDev/Pagerfanta.git", - "reference": "301903c1be505769e932ba5523cc01969f954d37" + "reference": "b09216fc53665c4d8a39b7f60e421165cb4693e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/BabDev/Pagerfanta/zipball/301903c1be505769e932ba5523cc01969f954d37", - "reference": "301903c1be505769e932ba5523cc01969f954d37", + "url": "https://api.github.com/repos/BabDev/Pagerfanta/zipball/b09216fc53665c4d8a39b7f60e421165cb4693e4", + "reference": "b09216fc53665c4d8a39b7f60e421165cb4693e4", "shasum": "" }, "require": { @@ -6062,11 +6116,11 @@ "doctrine/orm": "^2.14 || ^3.0", "doctrine/phpcr-odm": "^1.7 || ^2.0", "jackalope/jackalope-doctrine-dbal": "^1.9 || ^2.0", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "1.11.10", - "phpstan/phpstan-phpunit": "1.4.0", - "phpunit/phpunit": "10.5.30", - "rector/rector": "1.2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.0.3", + "phpstan/phpstan-phpunit": "2.0.1", + "phpunit/phpunit": "10.5.39", + "rector/rector": "2.0.3", "ruflin/elastica": "^7.3 || ^8.0", "solarium/solarium": "^6.2", "symfony/cache": "^5.4 || ^6.3 || ^7.0", @@ -6105,7 +6159,7 @@ ], "support": { "issues": "https://github.com/BabDev/Pagerfanta/issues", - "source": "https://github.com/BabDev/Pagerfanta/tree/v4.7.0" + "source": "https://github.com/BabDev/Pagerfanta/tree/v4.7.1" }, "funding": [ { @@ -6113,7 +6167,7 @@ "type": "github" } ], - "time": "2024-08-13T23:52:34+00:00" + "time": "2024-12-13T15:12:11+00:00" }, { "name": "paragonie/sodium_compat", @@ -7483,16 +7537,16 @@ }, { "name": "pusher/pusher-php-server", - "version": "7.2.6", + "version": "7.2.7", "source": { "type": "git", "url": "https://github.com/pusher/pusher-http-php.git", - "reference": "d89e9997191d18fb0fe03a956fa3ccfe0af524ea" + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/d89e9997191d18fb0fe03a956fa3ccfe0af524ea", - "reference": "d89e9997191d18fb0fe03a956fa3ccfe0af524ea", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7", + "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7", "shasum": "" }, "require": { @@ -7538,9 +7592,9 @@ ], "support": { "issues": "https://github.com/pusher/pusher-http-php/issues", - "source": "https://github.com/pusher/pusher-http-php/tree/7.2.6" + "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7" }, - "time": "2024-10-18T12:04:31+00:00" + "time": "2025-01-06T10:56:20+00:00" }, { "name": "ralouphie/getallheaders", @@ -8979,12 +9033,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -9186,16 +9240,16 @@ }, { "name": "symfony/console", - "version": "v6.4.15", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd" + "reference": "799445db3f15768ecc382ac5699e6da0520a0a04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", - "reference": "f1fc6f47283e27336e7cebb9e8946c8de7bff9bd", + "url": "https://api.github.com/repos/symfony/console/zipball/799445db3f15768ecc382ac5699e6da0520a0a04", + "reference": "799445db3f15768ecc382ac5699e6da0520a0a04", "shasum": "" }, "require": { @@ -9260,7 +9314,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.15" + "source": "https://github.com/symfony/console/tree/v6.4.17" }, "funding": [ { @@ -9276,7 +9330,7 @@ "type": "tidelift" } ], - "time": "2024-11-06T14:19:14+00:00" + "time": "2024-12-07T12:07:30+00:00" }, { "name": "symfony/css-selector", @@ -9443,12 +9497,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -9493,16 +9547,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "429a4b6786901afcc085ee16dc3f2be621f33488" + "reference": "2ba7e747a944b69f9f583c35173560afebbff995" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/429a4b6786901afcc085ee16dc3f2be621f33488", - "reference": "429a4b6786901afcc085ee16dc3f2be621f33488", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/2ba7e747a944b69f9f583c35173560afebbff995", + "reference": "2ba7e747a944b69f9f583c35173560afebbff995", "shasum": "" }, "require": { @@ -9581,7 +9635,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.16" + "source": "https://github.com/symfony/doctrine-bridge/tree/v6.4.17" }, "funding": [ { @@ -9597,7 +9651,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T12:00:20+00:00" + "time": "2024-12-18T10:42:42+00:00" }, { "name": "symfony/doctrine-messenger", @@ -9814,16 +9868,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.14", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9" + "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/9e024324511eeb00983ee76b9aedc3e6ecd993d9", - "reference": "9e024324511eeb00983ee76b9aedc3e6ecd993d9", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/37ad2380e8c1a8cf62a1200a5c10080b679b446c", + "reference": "37ad2380e8c1a8cf62a1200a5c10080b679b446c", "shasum": "" }, "require": { @@ -9869,7 +9923,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.14" + "source": "https://github.com/symfony/error-handler/tree/v6.4.17" }, "funding": [ { @@ -9885,7 +9939,7 @@ "type": "tidelift" } ], - "time": "2024-11-05T15:34:40+00:00" + "time": "2024-12-06T13:30:51+00:00" }, { "name": "symfony/event-dispatcher", @@ -9987,12 +10041,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -10175,16 +10229,16 @@ }, { "name": "symfony/finder", - "version": "v6.4.13", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958" + "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/daea9eca0b08d0ed1dc9ab702a46128fd1be4958", - "reference": "daea9eca0b08d0ed1dc9ab702a46128fd1be4958", + "url": "https://api.github.com/repos/symfony/finder/zipball/1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", + "reference": "1d0e8266248c5d9ab6a87e3789e6dc482af3c9c7", "shasum": "" }, "require": { @@ -10219,7 +10273,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.13" + "source": "https://github.com/symfony/finder/tree/v6.4.17" }, "funding": [ { @@ -10235,7 +10289,7 @@ "type": "tidelift" } ], - "time": "2024-10-01T08:30:56+00:00" + "time": "2024-12-29T13:51:37+00:00" }, { "name": "symfony/flex", @@ -10404,16 +10458,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v6.4.13", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "e8b0bd921f9bd35ea4d1508067c3f3f6e2036418" + "reference": "17d8ae2e7aa77154f942e8ac48849ac718b0963f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/e8b0bd921f9bd35ea4d1508067c3f3f6e2036418", - "reference": "e8b0bd921f9bd35ea4d1508067c3f3f6e2036418", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/17d8ae2e7aa77154f942e8ac48849ac718b0963f", + "reference": "17d8ae2e7aa77154f942e8ac48849ac718b0963f", "shasum": "" }, "require": { @@ -10533,7 +10587,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.13" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.17" }, "funding": [ { @@ -10549,27 +10603,27 @@ "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2024-12-19T14:08:41+00:00" }, { "name": "symfony/http-client", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "60a113666fa67e598abace38e5f46a0954d8833d" + "reference": "88898d842eb29d7e1a903724c94e90a6ca9c0509" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/60a113666fa67e598abace38e5f46a0954d8833d", - "reference": "60a113666fa67e598abace38e5f46a0954d8833d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/88898d842eb29d7e1a903724c94e90a6ca9c0509", + "reference": "88898d842eb29d7e1a903724c94e90a6ca9c0509", "shasum": "" }, "require": { "php": ">=8.1", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-client-contracts": "~3.4.3|^3.5.1", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -10626,7 +10680,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.4.16" + "source": "https://github.com/symfony/http-client/tree/v6.4.17" }, "funding": [ { @@ -10642,20 +10696,20 @@ "type": "tidelift" } ], - "time": "2024-11-27T11:52:33+00:00" + "time": "2024-12-18T12:18:31+00:00" }, { "name": "symfony/http-client-contracts", - "version": "v3.5.1", + "version": "v3.5.2", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "c2f3ad828596624ca39ea40f83617ef51ca8bbf9" + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/c2f3ad828596624ca39ea40f83617ef51ca8bbf9", - "reference": "c2f3ad828596624ca39ea40f83617ef51ca8bbf9", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/ee8d807ab20fcb51267fdace50fbe3494c31e645", + "reference": "ee8d807ab20fcb51267fdace50fbe3494c31e645", "shasum": "" }, "require": { @@ -10704,7 +10758,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.5.2" }, "funding": [ { @@ -10720,7 +10774,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T12:02:18+00:00" + "time": "2024-12-07T08:49:48+00:00" }, { "name": "symfony/http-foundation", @@ -10801,16 +10855,16 @@ }, { "name": "symfony/http-kernel", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0" + "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/8838b5b21d807923b893ccbfc2cbeda0f1bc00f0", - "reference": "8838b5b21d807923b893ccbfc2cbeda0f1bc00f0", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c5647393c5ce11833d13e4b70fff4b571d4ac710", + "reference": "c5647393c5ce11833d13e4b70fff4b571d4ac710", "shasum": "" }, "require": { @@ -10895,7 +10949,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.16" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.17" }, "funding": [ { @@ -10911,7 +10965,7 @@ "type": "tidelift" } ], - "time": "2024-11-27T12:49:36+00:00" + "time": "2024-12-31T14:49:31+00:00" }, { "name": "symfony/intl", @@ -11164,16 +11218,16 @@ }, { "name": "symfony/mime", - "version": "v6.4.13", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855" + "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/1de1cf14d99b12c7ebbb850491ec6ae3ed468855", - "reference": "1de1cf14d99b12c7ebbb850491ec6ae3ed468855", + "url": "https://api.github.com/repos/symfony/mime/zipball/ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", + "reference": "ea87c8850a54ff039d3e0ab4ae5586dd4e6c0232", "shasum": "" }, "require": { @@ -11229,7 +11283,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.13" + "source": "https://github.com/symfony/mime/tree/v6.4.17" }, "funding": [ { @@ -11245,7 +11299,7 @@ "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2024-12-02T11:09:41+00:00" }, { "name": "symfony/monolog-bridge", @@ -11792,8 +11846,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -11877,8 +11931,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -12043,8 +12097,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -12624,16 +12678,16 @@ }, { "name": "symfony/property-info", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "e4782ec1c2b6896e820896357f6a3d02249e63eb" + "reference": "38b125d78e67668159f75383a293ec0c5d3f2963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/e4782ec1c2b6896e820896357f6a3d02249e63eb", - "reference": "e4782ec1c2b6896e820896357f6a3d02249e63eb", + "url": "https://api.github.com/repos/symfony/property-info/zipball/38b125d78e67668159f75383a293ec0c5d3f2963", + "reference": "38b125d78e67668159f75383a293ec0c5d3f2963", "shasum": "" }, "require": { @@ -12688,7 +12742,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.16" + "source": "https://github.com/symfony/property-info/tree/v6.4.17" }, "funding": [ { @@ -12704,7 +12758,7 @@ "type": "tidelift" } ], - "time": "2024-11-27T10:18:02+00:00" + "time": "2024-12-26T19:01:29+00:00" }, { "name": "symfony/psr-http-message-bridge", @@ -13425,12 +13479,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -13812,12 +13866,12 @@ }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" } }, "autoload": { @@ -13873,16 +13927,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "32ec012ed4f6426441a66014471bdb26674744be" + "reference": "238e1aac992b5231c66faf10131ace7bdba97065" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/32ec012ed4f6426441a66014471bdb26674744be", - "reference": "32ec012ed4f6426441a66014471bdb26674744be", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/238e1aac992b5231c66faf10131ace7bdba97065", + "reference": "238e1aac992b5231c66faf10131ace7bdba97065", "shasum": "" }, "require": { @@ -13962,7 +14016,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.4.16" + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.17" }, "funding": [ { @@ -13978,7 +14032,7 @@ "type": "tidelift" } ], - "time": "2024-11-25T11:59:11+00:00" + "time": "2024-12-19T14:08:41+00:00" }, { "name": "symfony/twig-bundle", @@ -14223,16 +14277,16 @@ }, { "name": "symfony/validator", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "9b0d1988b56511706bc91d96ead39acd77aaf34d" + "reference": "a3c19a0e542d427c207e22242043ef35b5b99a2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/9b0d1988b56511706bc91d96ead39acd77aaf34d", - "reference": "9b0d1988b56511706bc91d96ead39acd77aaf34d", + "url": "https://api.github.com/repos/symfony/validator/zipball/a3c19a0e542d427c207e22242043ef35b5b99a2c", + "reference": "a3c19a0e542d427c207e22242043ef35b5b99a2c", "shasum": "" }, "require": { @@ -14300,7 +14354,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v6.4.16" + "source": "https://github.com/symfony/validator/tree/v6.4.17" }, "funding": [ { @@ -14316,7 +14370,7 @@ "type": "tidelift" } ], - "time": "2024-11-27T09:48:51+00:00" + "time": "2024-12-29T12:50:19+00:00" }, { "name": "symfony/var-dumper", @@ -14739,16 +14793,16 @@ }, { "name": "twig/twig", - "version": "v3.16.0", + "version": "v3.18.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "475ad2dc97d65d8631393e721e7e44fb544f0561" + "reference": "acffa88cc2b40dbe42eaf3a5025d6c0d4600cc50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/475ad2dc97d65d8631393e721e7e44fb544f0561", - "reference": "475ad2dc97d65d8631393e721e7e44fb544f0561", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/acffa88cc2b40dbe42eaf3a5025d6c0d4600cc50", + "reference": "acffa88cc2b40dbe42eaf3a5025d6c0d4600cc50", "shasum": "" }, "require": { @@ -14803,7 +14857,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.16.0" + "source": "https://github.com/twigphp/Twig/tree/v3.18.0" }, "funding": [ { @@ -14815,7 +14869,7 @@ "type": "tidelift" } ], - "time": "2024-11-29T08:27:05+00:00" + "time": "2024-12-29T10:51:50+00:00" }, { "name": "webmozart/assert", @@ -14935,7 +14989,7 @@ "packages-dev": [ { "name": "alchemy/api-test", - "version": "dev-PS-705-novu", + "version": "dev-PS-706-discussions", "dist": { "type": "path", "url": "../../lib/php/api-test", @@ -15077,13 +15131,13 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - }, "phpstan": { "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-main": "3.x-dev" } }, "autoload": { @@ -15339,16 +15393,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.65.0", + "version": "v3.66.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f" + "reference": "cde186799d8e92960c5a00c96e6214bf7f5547a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/79d4f3e77b250a7d8043d76c6af8f0695e8a469f", - "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/cde186799d8e92960c5a00c96e6214bf7f5547a9", + "reference": "cde186799d8e92960c5a00c96e6214bf7f5547a9", "shasum": "" }, "require": { @@ -15365,17 +15419,17 @@ "react/promise": "^2.0 || ^3.0", "react/socket": "^1.0", "react/stream": "^1.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", - "symfony/polyfill-mbstring": "^1.28", - "symfony/polyfill-php80": "^1.28", - "symfony/polyfill-php81": "^1.28", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0" + "sebastian/diff": "^4.0 || ^5.1 || ^6.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", + "symfony/finder": "^5.4 || ^6.4 || ^7.0", + "symfony/options-resolver": "^5.4 || ^6.4 || ^7.0", + "symfony/polyfill-mbstring": "^1.31", + "symfony/polyfill-php80": "^1.31", + "symfony/polyfill-php81": "^1.31", + "symfony/process": "^5.4 || ^6.4 || ^7.2", + "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0" }, "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.4", @@ -15387,9 +15441,9 @@ "php-cs-fixer/accessible-object": "^1.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.5", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.5", - "phpunit/phpunit": "^9.6.21 || ^10.5.38 || ^11.4.3", - "symfony/var-dumper": "^5.4.47 || ^6.4.15 || ^7.1.8", - "symfony/yaml": "^5.4.45 || ^6.4.13 || ^7.1.6" + "phpunit/phpunit": "^9.6.22 || ^10.5.40 || ^11.5.2", + "symfony/var-dumper": "^5.4.48 || ^6.4.15 || ^7.2.0", + "symfony/yaml": "^5.4.45 || ^6.4.13 || ^7.2.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -15430,7 +15484,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.65.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.66.1" }, "funding": [ { @@ -15438,7 +15492,7 @@ "type": "github" } ], - "time": "2024-11-25T00:39:24+00:00" + "time": "2025-01-05T14:43:25+00:00" }, { "name": "justinrainbow/json-schema", @@ -15507,16 +15561,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.4.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "447a020a1f875a434d62f2a401f53b82a396e494" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", + "reference": "447a020a1f875a434d62f2a401f53b82a396e494", "shasum": "" }, "require": { @@ -15559,9 +15613,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2024-12-30T11:07:19+00:00" }, { "name": "phar-io/manifest", @@ -15736,16 +15790,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.12", + "version": "1.12.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "b5ae1b88f471d3fd4ba1aa0046234b5ca3776dd0" + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b5ae1b88f471d3fd4ba1aa0046234b5ca3776dd0", - "reference": "b5ae1b88f471d3fd4ba1aa0046234b5ca3776dd0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c91d4e8bc056f46cf653656e6f71004b254574d1", + "reference": "c91d4e8bc056f46cf653656e6f71004b254574d1", "shasum": "" }, "require": { @@ -15790,7 +15844,7 @@ "type": "github" } ], - "time": "2024-11-28T22:13:23+00:00" + "time": "2025-01-05T16:40:22+00:00" }, { "name": "phpunit/php-code-coverage", @@ -16288,33 +16342,33 @@ }, { "name": "react/child-process", - "version": "v0.6.5", + "version": "v0.6.6", "source": { "type": "git", "url": "https://github.com/reactphp/child-process.git", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43" + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/1721e2b93d89b745664353b9cfc8f155ba8a6159", + "reference": "1721e2b93d89b745664353b9cfc8f155ba8a6159", "shasum": "" }, "require": { "evenement/evenement": "^3.0 || ^2.0 || ^1.0", "php": ">=5.3.0", "react/event-loop": "^1.2", - "react/stream": "^1.2" + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", - "react/socket": "^1.8", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" }, "type": "library", "autoload": { "psr-4": { - "React\\ChildProcess\\": "src" + "React\\ChildProcess\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -16351,19 +16405,15 @@ ], "support": { "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.5" + "source": "https://github.com/reactphp/child-process/tree/v0.6.6" }, "funding": [ { - "url": "https://github.com/WyriHaximus", - "type": "github" - }, - { - "url": "https://github.com/clue", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2022-09-16T13:41:56+00:00" + "time": "2025-01-01T16:37:48+00:00" }, { "name": "react/dns", @@ -17639,16 +17689,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v6.4.16", + "version": "v6.4.17", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "2d58fd04ac0d3c6279cadd0105959083ef1d7f5b" + "reference": "979f8ee1a4f2464c20f3fef0d2111827fef2e97e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/2d58fd04ac0d3c6279cadd0105959083ef1d7f5b", - "reference": "2d58fd04ac0d3c6279cadd0105959083ef1d7f5b", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/979f8ee1a4f2464c20f3fef0d2111827fef2e97e", + "reference": "979f8ee1a4f2464c20f3fef0d2111827fef2e97e", "shasum": "" }, "require": { @@ -17701,7 +17751,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.16" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.17" }, "funding": [ { @@ -17717,7 +17767,7 @@ "type": "tidelift" } ], - "time": "2024-11-19T10:11:25+00:00" + "time": "2024-12-08T23:00:41+00:00" }, { "name": "theseer/tokenizer", @@ -17776,6 +17826,7 @@ "alchemy/admin-bundle": 20, "alchemy/configurator-bundle": 20, "alchemy/core-bundle": 20, + "alchemy/notify-bundle": 20, "alchemy/es-bundle": 20, "alchemy/messenger-bundle": 20, "alchemy/metadata-manipulator-bundle": 20, diff --git a/databox/api/config/bundles.php b/databox/api/config/bundles.php index 547c8b9aa..25b2df322 100644 --- a/databox/api/config/bundles.php +++ b/databox/api/config/bundles.php @@ -35,4 +35,5 @@ Alchemy\RenditionFactoryBundle\AlchemyRenditionFactoryBundle::class => ['all' => true], Alchemy\ConfiguratorBundle\AlchemyConfiguratorBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], + Alchemy\NotifyBundle\AlchemyNotifyBundle::class => ['all' => true], ]; diff --git a/databox/api/config/packages/arthem_object_reference.yaml b/databox/api/config/packages/arthem_object_reference.yaml index 215a5d628..de71a2e06 100644 --- a/databox/api/config/packages/arthem_object_reference.yaml +++ b/databox/api/config/packages/arthem_object_reference.yaml @@ -1,5 +1,6 @@ arthem_object_reference: mapping: asset: App\Entity\Core\Asset + collection: App\Entity\Core\Collection file: App\Entity\Core\File basket: App\Entity\Basket\Basket diff --git a/databox/api/config/packages/fos_elastica.yaml b/databox/api/config/packages/fos_elastica.yaml index 63fa7d1dc..d58e385de 100644 --- a/databox/api/config/packages/fos_elastica.yaml +++ b/databox/api/config/packages/fos_elastica.yaml @@ -372,6 +372,35 @@ fos_elastica: provider: query_builder_method: getESQueryBuilder + message: + settings: + index: + analysis: + analyzer: + text: *text_analyzer + filter: + worddelimiter: *worddelimiter_filter + use_alias: '%elastica.use_alias%' + index_name: "%es_index_prefix%message_%kernel.environment%" + properties: + content: + type: text + analyzer: text + authorId: + type: keyword + users: + property_path: false + type: keyword + groups: + property_path: false + type: keyword + persistence: + driver: orm + model: App\Entity\Discussion\Message + listener: { enabled: false } + provider: + query_builder_method: getESQueryBuilder + when@dev: parameters: elastica.use_alias: false diff --git a/databox/api/migrations/Version20241230150924.php b/databox/api/migrations/Version20241230150924.php new file mode 100644 index 000000000..8eb056f3f --- /dev/null +++ b/databox/api/migrations/Version20241230150924.php @@ -0,0 +1,45 @@ +addSql('CREATE TABLE message (id UUID NOT NULL, thread_id UUID NOT NULL, author_id VARCHAR(36) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_B6BD307FE2904019 ON message (thread_id)'); + $this->addSql('COMMENT ON COLUMN message.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN message.thread_id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN message.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN message.updated_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE thread (id UUID NOT NULL, key VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_31204C838A90ABA9 ON thread (key)'); + $this->addSql('COMMENT ON COLUMN thread.id IS \'(DC2Type:uuid)\''); + $this->addSql('COMMENT ON COLUMN thread.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN thread.updated_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FE2904019 FOREIGN KEY (thread_id) REFERENCES thread (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FE2904019'); + $this->addSql('DROP TABLE message'); + $this->addSql('DROP TABLE thread'); + } +} diff --git a/databox/api/migrations/Version20250106104251.php b/databox/api/migrations/Version20250106104251.php new file mode 100644 index 000000000..6a49ce889 --- /dev/null +++ b/databox/api/migrations/Version20250106104251.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE thread ADD topic_key VARCHAR(255) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_31204C83B933DA0B ON thread (topic_key)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX UNIQ_31204C83B933DA0B'); + $this->addSql('ALTER TABLE thread DROP topic_key'); + } +} diff --git a/databox/api/migrations/Version20250106174830.php b/databox/api/migrations/Version20250106174830.php new file mode 100644 index 000000000..8de31e0a7 --- /dev/null +++ b/databox/api/migrations/Version20250106174830.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE message ADD attachments JSON DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE message DROP attachments'); + } +} diff --git a/databox/api/src/Api/Model/Input/EditThreadMessageInput.php b/databox/api/src/Api/Model/Input/EditThreadMessageInput.php new file mode 100644 index 000000000..aa1c5c5d4 --- /dev/null +++ b/databox/api/src/Api/Model/Input/EditThreadMessageInput.php @@ -0,0 +1,13 @@ +threadKey && null === $this->threadId) { + throw new \InvalidArgumentException('You must provide either a "threadKey" or a "threadId"'); + } + } +} diff --git a/databox/api/src/Api/Model/Output/AssetOutput.php b/databox/api/src/Api/Model/Output/AssetOutput.php index e105f8030..5b2e8d728 100644 --- a/databox/api/src/Api/Model/Output/AssetOutput.php +++ b/databox/api/src/Api/Model/Output/AssetOutput.php @@ -14,6 +14,7 @@ use App\Entity\Core\AssetRendition; use App\Entity\Core\File; use App\Entity\Core\Share; +use App\Entity\Discussion\Thread; use Symfony\Component\Serializer\Annotation\Groups; class AssetOutput extends AbstractUuidOutput @@ -56,6 +57,12 @@ class AssetOutput extends AbstractUuidOutput #[Groups([Asset::GROUP_LIST, Asset::GROUP_READ])] private ?string $titleHighlight = null; + #[Groups([Asset::GROUP_READ])] + public ?Thread $thread = null; + + #[Groups([Asset::GROUP_READ])] + public ?string $threadKey = null; + #[Groups([Asset::GROUP_LIST, Asset::GROUP_READ, WebhookSerializationInterface::DEFAULT_GROUP])] private int $privacy; diff --git a/databox/api/src/Api/Model/Output/ESDocumentStateOutput.php b/databox/api/src/Api/Model/Output/ESDocumentStateOutput.php index 6c087d14f..5d8d4ac78 100644 --- a/databox/api/src/Api/Model/Output/ESDocumentStateOutput.php +++ b/databox/api/src/Api/Model/Output/ESDocumentStateOutput.php @@ -13,8 +13,7 @@ public function __construct( private array $data, #[Groups(['_'])] private bool $synced, - ) - { + ) { } public function getData(): array diff --git a/databox/api/src/Api/Model/Output/ThreadMessageOutput.php b/databox/api/src/Api/Model/Output/ThreadMessageOutput.php new file mode 100644 index 000000000..e3880738d --- /dev/null +++ b/databox/api/src/Api/Model/Output/ThreadMessageOutput.php @@ -0,0 +1,40 @@ + 'object', + 'properties' => [ + 'canEdit' => 'boolean', + 'canDelete' => 'boolean', + ], + ])] + #[Groups([Message::GROUP_LIST, Message::GROUP_READ])] + public array $capabilities = []; +} diff --git a/databox/api/src/Api/OutputTransformer/AssetOutputTransformer.php b/databox/api/src/Api/OutputTransformer/AssetOutputTransformer.php index ece7c9e98..ac6c26355 100644 --- a/databox/api/src/Api/OutputTransformer/AssetOutputTransformer.php +++ b/databox/api/src/Api/OutputTransformer/AssetOutputTransformer.php @@ -24,6 +24,7 @@ use App\Security\RenditionPermissionManager; use App\Security\Voter\AbstractVoter; use App\Security\Voter\AssetVoter; +use App\Service\DiscussionManager; use Doctrine\ORM\EntityManagerInterface; class AssetOutputTransformer implements OutputTransformerInterface @@ -43,6 +44,7 @@ public function __construct( private readonly FieldNameResolver $fieldNameResolver, private readonly FacetRegistry $facetRegistry, private readonly AttributeTypeRegistry $attributeTypeRegistry, + private readonly DiscussionManager $discussionManager, ) { } @@ -185,6 +187,11 @@ public function transform(object $data, string $outputClass, array &$context = [ ]); } + if ($this->hasGroup([Asset::GROUP_READ], $context)) { + $output->threadKey = $this->discussionManager->getObjectKey($data); + $output->thread = $this->discussionManager->getThreadOfObject($data); + } + return $output; } diff --git a/databox/api/src/Api/OutputTransformer/ThreadMessageOutputTransformer.php b/databox/api/src/Api/OutputTransformer/ThreadMessageOutputTransformer.php new file mode 100644 index 000000000..a1494cbd6 --- /dev/null +++ b/databox/api/src/Api/OutputTransformer/ThreadMessageOutputTransformer.php @@ -0,0 +1,58 @@ +setCreatedAt($data->getCreatedAt()); + $output->setUpdatedAt($data->getUpdatedAt()); + $output->setId($data->getId()); + + $output->content = $data->getContent(); + $output->attachments = $data->getAttachments(); + $output->thread = $data->getThread(); + + if ($this->hasGroup([ + Message::GROUP_LIST, + Message::GROUP_READ, + ], $context)) { + $output->author = $this->transformUser($data->getAuthorId()); + $output->capabilities = [ + 'canEdit' => $this->isGranted(AbstractVoter::EDIT, $data), + 'canDelete' => $this->isGranted(AbstractVoter::DELETE, $data), + ]; + } + + return $output; + } +} diff --git a/databox/api/src/Api/Processor/PostMessageProcessor.php b/databox/api/src/Api/Processor/PostMessageProcessor.php new file mode 100644 index 000000000..2c19086e4 --- /dev/null +++ b/databox/api/src/Api/Processor/PostMessageProcessor.php @@ -0,0 +1,72 @@ +getStrictUser(); + + if ($threadId = $data->threadId) { + $thread = DoctrineUtil::findStrictByRepo($this->threadRepository, $threadId); + } else { + $thread = $this->threadRepository->findOneBy([ + 'key' => $data->threadKey, + ]); + + if (null === $thread) { + $thread = new Thread(); + $thread->setKey($data->threadKey); + $this->em->persist($thread); + } + } + + $this->denyAccessUnlessGranted(AbstractVoter::EDIT, $thread); + + $message = new Message(); + $message->setThread($thread); + $message->setAuthorId($user->getId()); + $message->setContent($data->content); + $message->setAttachments($data->attachments); + $this->em->persist($message); + $this->em->flush(); + + $this->discussionPusher->dispatchMessageToThread($message); + + $this->bus->dispatch(new PostDiscussionMessage($message->getId())); + + return $message; + } +} diff --git a/databox/api/src/Api/Processor/PutMessageProcessor.php b/databox/api/src/Api/Processor/PutMessageProcessor.php new file mode 100644 index 000000000..212b0e3c9 --- /dev/null +++ b/databox/api/src/Api/Processor/PutMessageProcessor.php @@ -0,0 +1,47 @@ +messageRepository, $uriVariables['id']); + + $this->denyAccessUnlessGranted(AbstractVoter::EDIT, $message); + + $message->setContent($data->content); + $this->em->persist($message); + $this->em->flush(); + + $this->discussionPusher->dispatchMessageToThread($message); + + return $message; + } +} diff --git a/databox/api/src/Api/Provider/ThreadMessagesProvider.php b/databox/api/src/Api/Provider/ThreadMessagesProvider.php new file mode 100644 index 000000000..1cd3b02a4 --- /dev/null +++ b/databox/api/src/Api/Provider/ThreadMessagesProvider.php @@ -0,0 +1,41 @@ +em->find(Thread::class, $threadId) + ?? throw new NotFoundHttpException(sprintf('Thread %s not found', $threadId)); + + $this->denyAccessUnlessGranted(AbstractVoter::READ, $thread); + + $filters = $context['filters'] ?? []; + $filters['threadId'] = $threadId; + + $context['filters'] = $filters; + + return $this->collectionProvider->provide($operation, $uriVariables, $context); + } +} diff --git a/databox/api/src/Command/DocumentationDumperCommand.php b/databox/api/src/Command/DocumentationDumperCommand.php index 32aded42d..f927639d6 100644 --- a/databox/api/src/Command/DocumentationDumperCommand.php +++ b/databox/api/src/Command/DocumentationDumperCommand.php @@ -4,26 +4,24 @@ namespace App\Command; +use Alchemy\RenditionFactory\RenditionBuilderConfigurationDocumentation; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Alchemy\RenditionFactory\RenditionBuilderConfigurationDocumentation; - #[AsCommand('app:documentation:dump')] class DocumentationDumperCommand extends Command { public function __construct( private readonly RenditionBuilderConfigurationDocumentation $renditionBuilderConfigurationDocumentation, - ) - { + ) { parent::__construct(); } protected function execute(InputInterface $input, OutputInterface $output): int { - $output->writeln('# ' . $this->renditionBuilderConfigurationDocumentation::getName()); + $output->writeln('# '.$this->renditionBuilderConfigurationDocumentation::getName()); $output->writeln($this->renditionBuilderConfigurationDocumentation->generate()); return Command::SUCCESS; diff --git a/databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessage.php b/databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessage.php new file mode 100644 index 000000000..bcfc05ecf --- /dev/null +++ b/databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessage.php @@ -0,0 +1,19 @@ +id; + } +} diff --git a/databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessageHandler.php b/databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessageHandler.php new file mode 100644 index 000000000..f70f438c1 --- /dev/null +++ b/databox/api/src/Consumer/Handler/Discussion/PostDiscussionMessageHandler.php @@ -0,0 +1,46 @@ +messageRepository->find($message->getId()); + if (!$message) { + return; + } + + $topicKey = $message->getThread()->getKey(); + + $object = $this->discussionManager->getThreadObject($message->getThread()); + $authorId = $message->getAuthorId(); + $author = $this->userRepository->getUser($authorId); + + $this->notifier->addTopicSubscribers($topicKey, [$authorId]); + $this->notifier->notifyTopic($topicKey, $authorId, 'databox-discussion-new-comment', [ + 'author' => $author ? $author['username'] : 'Deleted user', + 'object' => $object instanceof ObjectTitleInterface ? $object->getObjectTitle() : 'Undefined object', + ]); + } +} diff --git a/databox/api/src/Controller/Admin/JobStateCrudController.php b/databox/api/src/Controller/Admin/JobStateCrudController.php index 670071ddf..1a87177f8 100644 --- a/databox/api/src/Controller/Admin/JobStateCrudController.php +++ b/databox/api/src/Controller/Admin/JobStateCrudController.php @@ -24,7 +24,6 @@ use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter; use EasyCorp\Bundle\EasyAdminBundle\Filter\DateTimeFilter; use EasyCorp\Bundle\EasyAdminBundle\Filter\NumericFilter; -use EasyCorp\Bundle\EasyAdminBundle\Filter\TextFilter; use Symfony\Component\HttpFoundation\RedirectResponse; class JobStateCrudController extends AbstractAdminCrudController diff --git a/databox/api/src/Doctrine/Listener/FileListener.php b/databox/api/src/Doctrine/Listener/FileListener.php index 8477adeee..663cec2e9 100644 --- a/databox/api/src/Doctrine/Listener/FileListener.php +++ b/databox/api/src/Doctrine/Listener/FileListener.php @@ -5,7 +5,6 @@ namespace App\Doctrine\Listener; use Alchemy\MessengerBundle\Listener\PostFlushStack; -use App\Consumer\Handler\File\DeleteFileFromStorage; use App\Consumer\Handler\File\DeleteFilesIfOrphan; use App\Entity\Core\Asset; use App\Entity\Core\AssetFileVersion; @@ -46,7 +45,6 @@ private function addFileToDelete(?File $file): void $this->postFlushStack->addBusMessage(new DeleteFilesIfOrphan([$file->getId()])); } - public function getSubscribedEvents(): array { return [ diff --git a/databox/api/src/Doctrine/Listener/ThreadMessageListener.php b/databox/api/src/Doctrine/Listener/ThreadMessageListener.php new file mode 100644 index 000000000..c87fd192b --- /dev/null +++ b/databox/api/src/Doctrine/Listener/ThreadMessageListener.php @@ -0,0 +1,41 @@ +getObject(); + + if ($object instanceof Message) { + $this->postFlushStack->addCallback(function () use ($object): void { + $this->discussionPusher->dispatchMessageToThread($object, removed: true); + }); + } + } + + public function getSubscribedEvents(): array + { + return [ + Events::preRemove, + ]; + } +} diff --git a/databox/api/src/Elasticsearch/ESDocumentStateManager.php b/databox/api/src/Elasticsearch/ESDocumentStateManager.php index fc4e69acd..132b8ed59 100644 --- a/databox/api/src/Elasticsearch/ESDocumentStateManager.php +++ b/databox/api/src/Elasticsearch/ESDocumentStateManager.php @@ -7,7 +7,6 @@ use Elastica\Request; use FOS\ElasticaBundle\Persister\ObjectPersister; use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; -use RuntimeException; use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; final readonly class ESDocumentStateManager @@ -43,6 +42,6 @@ private function getObjectPersister(object $object): ObjectPersister } } - throw new RuntimeException(sprintf('No object persister found for object of class %s', get_class($object))); + throw new \RuntimeException(sprintf('No object persister found for object of class %s', get_class($object))); } } diff --git a/databox/api/src/Elasticsearch/Listener/MessagePostTransformListener.php b/databox/api/src/Elasticsearch/Listener/MessagePostTransformListener.php new file mode 100644 index 000000000..a12878e77 --- /dev/null +++ b/databox/api/src/Elasticsearch/Listener/MessagePostTransformListener.php @@ -0,0 +1,35 @@ +getObject()) instanceof Message) { + return; + } + + $document = $event->getDocument(); + $document->set('users', []); + $document->set('groups', []); + } + + public static function getSubscribedEvents(): array + { + return [ + PostTransformEvent::class => 'hydrateDocument', + ]; + } +} diff --git a/databox/api/src/Entity/Basket/Basket.php b/databox/api/src/Entity/Basket/Basket.php index 28b31afff..9839283da 100644 --- a/databox/api/src/Entity/Basket/Basket.php +++ b/databox/api/src/Entity/Basket/Basket.php @@ -7,6 +7,8 @@ use Alchemy\AclBundle\AclObjectInterface; use Alchemy\AuthBundle\Security\JwtUser; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use Alchemy\ESBundle\Indexer\ESIndexableInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -21,9 +23,7 @@ use App\Api\Processor\AddToBasketProcessor; use App\Api\Processor\RemoveFromBasketProcessor; use App\Api\Provider\BasketCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\OwnerIdTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\WithOwnerIdInterface; use App\Repository\Basket\BasketRepository; use App\Security\Voter\AbstractVoter; diff --git a/databox/api/src/Entity/Basket/BasketAsset.php b/databox/api/src/Entity/Basket/BasketAsset.php index 9dbeda745..c839d1026 100644 --- a/databox/api/src/Entity/Basket/BasketAsset.php +++ b/databox/api/src/Entity/Basket/BasketAsset.php @@ -5,13 +5,13 @@ namespace App\Entity\Basket; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; use App\Api\Provider\BasketAssetCollectionProvider; use App\Entity\Core\Asset; use App\Entity\Traits\AssetAnnotationsTrait; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\OwnerIdTrait; use App\Entity\WithOwnerIdInterface; use Doctrine\DBAL\Types\Types; diff --git a/databox/api/src/Entity/Core/Asset.php b/databox/api/src/Entity/Core/Asset.php index 25688704f..1fbf13603 100644 --- a/databox/api/src/Entity/Core/Asset.php +++ b/databox/api/src/Entity/Core/Asset.php @@ -7,6 +7,8 @@ use Alchemy\AclBundle\AclObjectInterface; use Alchemy\AuthBundle\Security\JwtUser; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use Alchemy\ESBundle\Indexer\ESIndexableDependencyInterface; use Alchemy\ESBundle\Indexer\ESIndexableInterface; use ApiPlatform\Metadata\ApiResource; @@ -28,8 +30,8 @@ use App\Api\Model\Output\MultipleAssetOutput; use App\Api\Model\Output\PrepareDeleteAssetsOutput; use App\Api\Processor\AssetAttributeBatchUpdateProcessor; -use App\Api\Processor\ItemElasticsearchDocumentSyncProcessor; use App\Api\Processor\CopyAssetProcessor; +use App\Api\Processor\ItemElasticsearchDocumentSyncProcessor; use App\Api\Processor\MoveAssetProcessor; use App\Api\Processor\MultipleAssetCreateProcessor; use App\Api\Processor\PrepareDeleteAssetProcessor; @@ -41,10 +43,9 @@ use App\Api\Provider\SearchSuggestionCollectionProvider; use App\Controller\Core\DeleteAssetByIdsAction; use App\Controller\Core\DeleteAssetByKeysAction; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use App\Entity\ObjectTitleInterface; use App\Entity\Traits\LocaleTrait; use App\Entity\Traits\OwnerIdTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\Traits\WorkspacePrivacyTrait; use App\Entity\Traits\WorkspaceTrait; use App\Entity\TranslatableInterface; @@ -182,7 +183,7 @@ #[ORM\Table] #[ORM\UniqueConstraint(name: 'uniq_ws_key', columns: ['workspace_id', 'key'])] #[ORM\Entity(repositoryClass: AssetRepository::class)] -class Asset extends AbstractUuidEntity implements HighlightableModelInterface, WithOwnerIdInterface, AclObjectInterface, TranslatableInterface, WorkspaceItemPrivacyInterface, ESIndexableInterface, ESIndexableDependencyInterface, \Stringable +class Asset extends AbstractUuidEntity implements HighlightableModelInterface, WithOwnerIdInterface, AclObjectInterface, TranslatableInterface, WorkspaceItemPrivacyInterface, ESIndexableInterface, ESIndexableDependencyInterface, ObjectTitleInterface, \Stringable { use CreatedAtTrait; use UpdatedAtTrait; @@ -511,4 +512,9 @@ public function getMicroseconds(): int { return $this->microseconds; } + + public function getObjectTitle(): string + { + return sprintf('Asset %s', $this->getTitle() ?? $this->getId()); + } } diff --git a/databox/api/src/Entity/Core/AssetFileVersion.php b/databox/api/src/Entity/Core/AssetFileVersion.php index 613398759..f18946dae 100644 --- a/databox/api/src/Entity/Core/AssetFileVersion.php +++ b/databox/api/src/Entity/Core/AssetFileVersion.php @@ -5,13 +5,13 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GetCollection; use App\Api\Provider\AssetFileVersionCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; diff --git a/databox/api/src/Entity/Core/AssetRelationship.php b/databox/api/src/Entity/Core/AssetRelationship.php index a34931102..61b6f8679 100644 --- a/databox/api/src/Entity/Core/AssetRelationship.php +++ b/databox/api/src/Entity/Core/AssetRelationship.php @@ -5,9 +5,9 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; -use App\Entity\Integration\WorkspaceIntegration; use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; +use App\Entity\Integration\WorkspaceIntegration; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; diff --git a/databox/api/src/Entity/Core/AssetRendition.php b/databox/api/src/Entity/Core/AssetRendition.php index 68d9dec96..7e44291c8 100644 --- a/databox/api/src/Entity/Core/AssetRendition.php +++ b/databox/api/src/Entity/Core/AssetRendition.php @@ -5,7 +5,8 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; -use ApiPlatform\Metadata\ApiProperty; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; @@ -16,8 +17,6 @@ use App\Api\Model\Input\AssetRenditionInput; use App\Api\Model\Output\AssetRenditionOutput; use App\Api\Provider\RenditionCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Repository\Core\AssetRenditionRepository; use App\Security\Voter\AbstractVoter; use Doctrine\DBAL\Types\Types; diff --git a/databox/api/src/Entity/Core/AttributeClass.php b/databox/api/src/Entity/Core/AttributeClass.php index 64850e196..3252a7270 100644 --- a/databox/api/src/Entity/Core/AttributeClass.php +++ b/databox/api/src/Entity/Core/AttributeClass.php @@ -7,6 +7,7 @@ use Alchemy\AclBundle\AclObjectInterface; use Alchemy\AuthBundle\Security\JwtUser; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; @@ -16,7 +17,6 @@ use ApiPlatform\Metadata\Put; use App\Api\Model\Input\AttributeClassInput; use App\Api\Provider\AttributeClassCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\WorkspaceTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection as DoctrineCollection; diff --git a/databox/api/src/Entity/Core/AttributeDefinition.php b/databox/api/src/Entity/Core/AttributeDefinition.php index f50f2f0c9..616c5d787 100644 --- a/databox/api/src/Entity/Core/AttributeDefinition.php +++ b/databox/api/src/Entity/Core/AttributeDefinition.php @@ -6,6 +6,8 @@ use Alchemy\AuthBundle\Security\JwtUser; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -20,8 +22,6 @@ use App\Attribute\AttributeInterface; use App\Attribute\Type\TextAttributeType; use App\Controller\Core\AttributeDefinitionSortAction; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\Traits\WorkspaceTrait; use App\Repository\Core\AttributeDefinitionRepository; use Doctrine\Common\Collections\Collection as DoctrineCollection; diff --git a/databox/api/src/Entity/Core/AttributeEntity.php b/databox/api/src/Entity/Core/AttributeEntity.php index 307be8c82..c2ac193e9 100644 --- a/databox/api/src/Entity/Core/AttributeEntity.php +++ b/databox/api/src/Entity/Core/AttributeEntity.php @@ -5,6 +5,8 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; @@ -15,8 +17,6 @@ use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Api\Provider\AttributeEntityCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\Traits\WorkspaceTrait; use App\Repository\Core\AttributeEntityRepository; use Doctrine\DBAL\Types\Types; diff --git a/databox/api/src/Entity/Core/Collection.php b/databox/api/src/Entity/Core/Collection.php index a95745b5a..8b4bb2741 100644 --- a/databox/api/src/Entity/Core/Collection.php +++ b/databox/api/src/Entity/Core/Collection.php @@ -6,6 +6,8 @@ use Alchemy\AclBundle\AclObjectInterface; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use Alchemy\ESBundle\Indexer\ESIndexableDeleteDependencyInterface; use Alchemy\ESBundle\Indexer\ESIndexableDependencyInterface; use Alchemy\ESBundle\Indexer\ESIndexableInterface; @@ -25,11 +27,10 @@ use App\Api\Provider\CollectionProvider; use App\Api\Provider\ItemElasticsearchDocumentProvider; use App\Doctrine\Listener\SoftDeleteableInterface; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use App\Entity\ObjectTitleInterface; use App\Entity\Traits\DeletedAtTrait; use App\Entity\Traits\LocaleTrait; use App\Entity\Traits\OwnerIdTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\Traits\WorkspacePrivacyTrait; use App\Entity\Traits\WorkspaceTrait; use App\Entity\TranslatableInterface; @@ -109,7 +110,7 @@ #[ORM\UniqueConstraint(name: 'uniq_coll_ws_key', columns: ['workspace_id', 'key'])] #[ORM\Entity(repositoryClass: CollectionRepository::class)] #[Gedmo\SoftDeleteable(fieldName: 'deletedAt', hardDelete: false)] -class Collection extends AbstractUuidEntity implements SoftDeleteableInterface, WithOwnerIdInterface, AclObjectInterface, TranslatableInterface, ESIndexableDependencyInterface, ESIndexableDeleteDependencyInterface, ESIndexableInterface, \Stringable +class Collection extends AbstractUuidEntity implements SoftDeleteableInterface, WithOwnerIdInterface, AclObjectInterface, TranslatableInterface, ESIndexableDependencyInterface, ESIndexableDeleteDependencyInterface, ESIndexableInterface, ObjectTitleInterface, \Stringable { use CreatedAtTrait; use UpdatedAtTrait; @@ -356,4 +357,9 @@ public function isObjectIndexable(): bool { return null === $this->workspace->getDeletedAt(); } + + public function getObjectTitle(): string + { + return sprintf('Collection %s', $this->getTitle()); + } } diff --git a/databox/api/src/Entity/Core/CollectionAsset.php b/databox/api/src/Entity/Core/CollectionAsset.php index be1ad422f..38d2b0b48 100644 --- a/databox/api/src/Entity/Core/CollectionAsset.php +++ b/databox/api/src/Entity/Core/CollectionAsset.php @@ -5,12 +5,12 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use Alchemy\ESBundle\Indexer\ESIndexableDeleteDependencyInterface; use Alchemy\ESBundle\Indexer\ESIndexableDependencyInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Post; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Repository\Core\CollectionAssetRepository; use Doctrine\ORM\Mapping as ORM; diff --git a/databox/api/src/Entity/Core/File.php b/databox/api/src/Entity/Core/File.php index a47b74680..5d1af79d3 100644 --- a/databox/api/src/Entity/Core/File.php +++ b/databox/api/src/Entity/Core/File.php @@ -5,10 +5,10 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; -use ApiPlatform\Metadata\ApiResource; -use App\Api\Model\Output\FileOutput; use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; +use ApiPlatform\Metadata\ApiResource; +use App\Api\Model\Output\FileOutput; use App\Entity\Traits\WorkspaceTrait; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; diff --git a/databox/api/src/Entity/Core/RenditionClass.php b/databox/api/src/Entity/Core/RenditionClass.php index 0c61c81b2..3cd78d019 100644 --- a/databox/api/src/Entity/Core/RenditionClass.php +++ b/databox/api/src/Entity/Core/RenditionClass.php @@ -6,16 +6,16 @@ use Alchemy\AuthBundle\Security\JwtUser; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; -use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Api\Provider\RenditionClassCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\WorkspaceTrait; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection as DoctrineCollection; diff --git a/databox/api/src/Entity/Core/RenditionRule.php b/databox/api/src/Entity/Core/RenditionRule.php index 75c86cf12..828d41b7e 100644 --- a/databox/api/src/Entity/Core/RenditionRule.php +++ b/databox/api/src/Entity/Core/RenditionRule.php @@ -6,6 +6,8 @@ use Alchemy\AuthBundle\Security\JwtUser; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; @@ -17,8 +19,6 @@ use ApiPlatform\Metadata\Put; use App\Api\Model\Input\RenditionRuleInput; use App\Api\Model\Output\RenditionRuleOutput; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Repository\Core\RenditionRuleRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection as DoctrineCollection; diff --git a/databox/api/src/Entity/Core/Share.php b/databox/api/src/Entity/Core/Share.php index a1e17b426..2c6be5ad2 100644 --- a/databox/api/src/Entity/Core/Share.php +++ b/databox/api/src/Entity/Core/Share.php @@ -5,6 +5,8 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; @@ -18,9 +20,7 @@ use App\Api\Provider\ShareCollectionProvider; use App\Api\Provider\ShareReadProvider; use App\Api\Provider\ShareRenditionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\OwnerIdTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Listener\OwnerPersistableInterface; use App\Repository\Core\ShareRepository; use App\Security\Voter\AbstractVoter; diff --git a/databox/api/src/Entity/Core/Tag.php b/databox/api/src/Entity/Core/Tag.php index 0156b59a0..5fee51ca5 100644 --- a/databox/api/src/Entity/Core/Tag.php +++ b/databox/api/src/Entity/Core/Tag.php @@ -5,6 +5,8 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; @@ -16,9 +18,7 @@ use App\Api\Model\Input\TagInput; use App\Api\Model\Output\TagOutput; use App\Api\Provider\TagCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\LocaleTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\Traits\WorkspaceTrait; use App\Entity\TranslatableInterface; use App\Repository\Core\TagRepository; diff --git a/databox/api/src/Entity/Core/TagFilterRule.php b/databox/api/src/Entity/Core/TagFilterRule.php index 66435e7be..430a4df61 100644 --- a/databox/api/src/Entity/Core/TagFilterRule.php +++ b/databox/api/src/Entity/Core/TagFilterRule.php @@ -6,6 +6,8 @@ use Alchemy\AuthBundle\Security\JwtUser; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; @@ -15,8 +17,6 @@ use App\Api\Model\Input\TagFilterRuleInput; use App\Api\Model\Output\TagFilterRuleOutput; use App\Api\Provider\TagFilterRuleCollectionProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Repository\Core\TagFilterRuleRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection as DoctrineCollection; diff --git a/databox/api/src/Entity/Core/Workspace.php b/databox/api/src/Entity/Core/Workspace.php index c66626091..571bfd07e 100644 --- a/databox/api/src/Entity/Core/Workspace.php +++ b/databox/api/src/Entity/Core/Workspace.php @@ -6,6 +6,8 @@ use Alchemy\AclBundle\AclObjectInterface; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -18,10 +20,8 @@ use App\Controller\Core\FlushWorkspaceAction; use App\Controller\Core\GetWorkspaceBySlugAction; use App\Doctrine\Listener\SoftDeleteableInterface; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\DeletedAtTrait; use App\Entity\Traits\OwnerIdTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\WithOwnerIdInterface; use App\Repository\Core\WorkspaceRepository; use App\Security\Voter\AbstractVoter; diff --git a/databox/api/src/Entity/Discussion/Message.php b/databox/api/src/Entity/Discussion/Message.php new file mode 100644 index 000000000..04a883509 --- /dev/null +++ b/databox/api/src/Entity/Discussion/Message.php @@ -0,0 +1,148 @@ + [self::GROUP_READ], + ], + security: 'is_granted("'.AbstractVoter::READ.'", object)', + ), + new Post( + normalizationContext: [ + 'groups' => [self::GROUP_READ], + ], + input: ThreadMessageInput::class, + processor: PostMessageProcessor::class, + ), + new Put( + normalizationContext: [ + 'groups' => [self::GROUP_READ], + ], + security: 'is_granted("'.AbstractVoter::EDIT.'", object)', + input: EditThreadMessageInput::class, + processor: PutMessageProcessor::class, + ), + new Delete( + security: 'is_granted("'.AbstractVoter::DELETE.'", object)', + ), + ], + normalizationContext: [ + 'groups' => [self::GROUP_LIST], + ], + output: ThreadMessageOutput::class, +)] +#[ApiResource( + uriTemplate: '/threads/{threadId}/messages', + shortName: 'message', + operations: [ + new GetCollection( + provider: ThreadMessagesProvider::class, + ), + ], + uriVariables: [ + 'threadId' => new Link( + toProperty: 'thread', + fromClass: Thread::class + ), + ], + normalizationContext: [ + 'groups' => [self::GROUP_LIST], + ], + order: ['createdAt' => 'ASC'], +)] +#[ApiFilter(SearchFilter::class, properties: [ + 'thread' => 'exact', +])] +#[ORM\Table] +#[ORM\Entity(repositoryClass: MessageRepository::class)] +class Message extends AbstractUuidEntity +{ + use CreatedAtTrait; + use UpdatedAtTrait; + + final public const string GROUP_READ = 'message:r'; + final public const string GROUP_LIST = 'message:i'; + final public const string GROUP_WRITE = 'message:w'; + + #[ORM\ManyToOne(targetEntity: Thread::class)] + #[ORM\JoinColumn(nullable: false)] + private ?Thread $thread = null; + + #[ORM\Column(type: Types::STRING, length: 36, nullable: false)] + private ?string $authorId = null; + + #[ORM\Column(type: Types::TEXT, nullable: false)] + private ?string $content = null; + + #[ORM\Column(type: Types::JSON, nullable: true)] + private ?array $attachments = null; + + public function getThread(): ?Thread + { + return $this->thread; + } + + public function setThread(?Thread $thread): void + { + $this->thread = $thread; + } + + public function getAuthorId(): ?string + { + return $this->authorId; + } + + public function setAuthorId(?string $authorId): void + { + $this->authorId = $authorId; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): void + { + $this->content = $content; + } + + public function getAttachments(): ?array + { + return $this->attachments; + } + + public function setAttachments(?array $attachments): void + { + $this->attachments = $attachments; + } +} diff --git a/databox/api/src/Entity/Discussion/Thread.php b/databox/api/src/Entity/Discussion/Thread.php new file mode 100644 index 000000000..e2ee61dbe --- /dev/null +++ b/databox/api/src/Entity/Discussion/Thread.php @@ -0,0 +1,49 @@ + [self::GROUP_LIST], + ], +)] +#[ORM\Table] +#[ORM\Entity(repositoryClass: ThreadRepository::class)] +class Thread extends AbstractUuidEntity +{ + use CreatedAtTrait; + use UpdatedAtTrait; + use NovuTopicKeyTrait; + + final public const string GROUP_READ = 'thread:r'; + final public const string GROUP_LIST = 'thread:i'; + final public const string GROUP_WRITE = 'thread:w'; + + #[ORM\Column(type: Types::STRING, length: 255, unique: true, nullable: false)] + private ?string $key = null; + + public function getKey(): ?string + { + return $this->key; + } + + public function setKey(?string $key): void + { + $this->key = $key; + } +} diff --git a/databox/api/src/Entity/Integration/IntegrationData.php b/databox/api/src/Entity/Integration/IntegrationData.php index 6692d8f04..c8fc3cd81 100644 --- a/databox/api/src/Entity/Integration/IntegrationData.php +++ b/databox/api/src/Entity/Integration/IntegrationData.php @@ -5,6 +5,8 @@ namespace App\Entity\Integration; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; @@ -16,8 +18,6 @@ use ApiPlatform\Metadata\Put; use App\Api\Model\Output\IntegrationDataOutput; use App\Api\Provider\IntegrationDataProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use Arthem\ObjectReferenceBundle\Mapping\Attribute\ObjectReference; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; diff --git a/databox/api/src/Entity/Integration/IntegrationToken.php b/databox/api/src/Entity/Integration/IntegrationToken.php index 2fb42cfcb..dccb05488 100644 --- a/databox/api/src/Entity/Integration/IntegrationToken.php +++ b/databox/api/src/Entity/Integration/IntegrationToken.php @@ -5,13 +5,13 @@ namespace App\Entity\Integration; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; use App\Api\Provider\IntegrationTokenDataProvider; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Security\Voter\AbstractVoter; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; diff --git a/databox/api/src/Entity/Integration/WorkspaceEnv.php b/databox/api/src/Entity/Integration/WorkspaceEnv.php index 4d756ee72..c025a38d5 100644 --- a/databox/api/src/Entity/Integration/WorkspaceEnv.php +++ b/databox/api/src/Entity/Integration/WorkspaceEnv.php @@ -5,11 +5,11 @@ namespace App\Entity\Integration; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\NullableWorkspaceTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; diff --git a/databox/api/src/Entity/Integration/WorkspaceIntegration.php b/databox/api/src/Entity/Integration/WorkspaceIntegration.php index 82ff22bd1..aab4f81bf 100644 --- a/databox/api/src/Entity/Integration/WorkspaceIntegration.php +++ b/databox/api/src/Entity/Integration/WorkspaceIntegration.php @@ -5,6 +5,8 @@ namespace App\Entity\Integration; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; @@ -15,9 +17,7 @@ use ApiPlatform\Metadata\Put; use App\Api\Model\Output\WorkspaceIntegrationOutput; use App\Entity\Core\Workspace; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\NullableWorkspaceTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Integration\Exception\CircularReferenceException; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; diff --git a/databox/api/src/Entity/Integration/WorkspaceSecret.php b/databox/api/src/Entity/Integration/WorkspaceSecret.php index 45564e422..0ba921aee 100644 --- a/databox/api/src/Entity/Integration/WorkspaceSecret.php +++ b/databox/api/src/Entity/Integration/WorkspaceSecret.php @@ -5,11 +5,11 @@ namespace App\Entity\Integration; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; use ApiPlatform\Metadata\ApiFilter; -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\NullableWorkspaceTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; diff --git a/databox/api/src/Entity/ObjectTitleInterface.php b/databox/api/src/Entity/ObjectTitleInterface.php new file mode 100644 index 000000000..d48e33632 --- /dev/null +++ b/databox/api/src/Entity/ObjectTitleInterface.php @@ -0,0 +1,8 @@ +topicKey; + } + + public function setTopicKey(?string $topicKey): void + { + $this->topicKey = $topicKey; + } + + public function hasNovuTopic(): bool + { + return null !== $this->topicKey; + } +} diff --git a/databox/api/src/Fixture/Faker/AssetAnnotationsFaker.php b/databox/api/src/Fixture/Faker/AssetAnnotationsFaker.php index 0b5dd6899..5279943bd 100644 --- a/databox/api/src/Fixture/Faker/AssetAnnotationsFaker.php +++ b/databox/api/src/Fixture/Faker/AssetAnnotationsFaker.php @@ -31,6 +31,7 @@ public function assetAnnotationsCircle(): array 'y' => $y, 'r' => min(1 - $x, 1 - $y, rand(5, 50) / 100), 'c' => $this->randomColor(), + 'page' => 2, ], ]; } @@ -46,6 +47,7 @@ public function assetAnnotationsPoint(): array 'x' => $x, 'y' => $y, 'c' => $this->randomColor(), + 'page' => 2, ], ]; } @@ -65,6 +67,7 @@ public function assetAnnotationsRect(): array 'x2' => $x2, 'y2' => $y2, 'c' => $this->randomColor(), + 'page' => 2, ], ]; } diff --git a/databox/api/src/Integration/Phrasea/Expose/ExposeClient.php b/databox/api/src/Integration/Phrasea/Expose/ExposeClient.php index eb67ff6ba..d5a609764 100644 --- a/databox/api/src/Integration/Phrasea/Expose/ExposeClient.php +++ b/databox/api/src/Integration/Phrasea/Expose/ExposeClient.php @@ -2,17 +2,17 @@ namespace App\Integration\Phrasea\Expose; +use Alchemy\StorageBundle\Upload\UploadManager; +use App\Asset\Attribute\AssetTitleResolver; +use App\Asset\Attribute\AttributesResolver; use App\Asset\FileFetcher; +use App\Attribute\AttributeInterface; use App\Entity\Core\Asset; use App\Entity\Core\Attribute; -use App\Storage\RenditionManager; -use App\Attribute\AttributeInterface; -use App\Integration\IntegrationConfig; -use App\Asset\Attribute\AssetTitleResolver; -use App\Asset\Attribute\AttributesResolver; use App\Entity\Integration\IntegrationToken; -use Alchemy\StorageBundle\Upload\UploadManager; +use App\Integration\IntegrationConfig; use App\Integration\Phrasea\PhraseaClientFactory; +use App\Storage\RenditionManager; use Symfony\Contracts\HttpClient\HttpClientInterface; final readonly class ExposeClient @@ -24,7 +24,7 @@ public function __construct( private AssetTitleResolver $assetTitleResolver, private AttributesResolver $attributesResolver, private RenditionManager $renditionManager, - private UploadManager $uploadManager + private UploadManager $uploadManager, ) { } @@ -129,7 +129,7 @@ public function postAsset(IntegrationConfig $config, IntegrationToken $integrati $source = $asset->getSource(); $fetchedFilePath = $this->fileFetcher->getFile($source); - $fileSize = filesize($fetchedFilePath); + $fileSize = filesize($fetchedFilePath); // @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html $partSize = 100 * 1024 * 1024; // 100Mb @@ -138,7 +138,7 @@ public function postAsset(IntegrationConfig $config, IntegrationToken $integrati $uploadsData = [ 'filename' => $source->getOriginalName(), 'type' => $source->getType(), - 'size' => (int)$source->getSize(), + 'size' => (int) $source->getSize(), ]; $resUploads = $this->create($config, $integrationToken) @@ -160,9 +160,9 @@ public function postAsset(IntegrationConfig $config, IntegrationToken $integrati $retryCount = 3; - while ( ($fileSize - $alreadyUploaded) > 0) { + while (($fileSize - $alreadyUploaded) > 0) { $resUploadPart = $this->create($config, $integrationToken) - ->request('POST', '/uploads/'. $mUploadId .'/part', [ + ->request('POST', '/uploads/'.$mUploadId.'/part', [ 'json' => ['part' => $partNumber], ]) ->toArray() @@ -173,23 +173,23 @@ public function postAsset(IntegrationConfig $config, IntegrationToken $integrati } $headerPutPart = $this->putPart($resUploadPart['url'], $fd, $partSize, $retryCount); - + $alreadyUploaded += $partSize; $parts['Parts'][$partNumber] = [ - 'PartNumber' => $partNumber, - 'ETag' => current($headerPutPart['etag']), + 'PartNumber' => $partNumber, + 'ETag' => current($headerPutPart['etag']), ]; - $partNumber++; + ++$partNumber; } - + fclose($fd); } catch (\Throwable $e) { $this->create($config, $integrationToken) - ->request('DELETE', '/uploads/'. $mUploadId); + ->request('DELETE', '/uploads/'.$mUploadId); - throw $e; + throw $e; } $data = array_merge([ @@ -199,8 +199,8 @@ public function postAsset(IntegrationConfig $config, IntegrationToken $integrati 'description' => $description, 'translations' => $translations, 'multipart' => [ - 'uploadId' => $mUploadId, - 'parts' => $parts['Parts'], + 'uploadId' => $mUploadId, + 'parts' => $parts['Parts'], ], ], $extraData); @@ -265,10 +265,11 @@ public function deleteAsset(IntegrationConfig $config, IntegrationToken $integra private function putPart(string $url, mixed &$handleFile, int $partSize, int $retryCount): array { if ($retryCount > 0) { - $retryCount--; + --$retryCount; try { $maxToRead = $partSize; $alreadyRead = 0; + return $this->uploadClient->request('PUT', $url, [ 'headers' => [ 'Content-Length' => $partSize, @@ -281,9 +282,10 @@ private function putPart(string $url, mixed &$handleFile, int $partSize, int $re }, ])->getHeaders(); } catch (\Throwable $e) { - if ($retryCount == 0) { + if (0 == $retryCount) { throw $e; } + return $this->putPart($url, $handleFile, $partSize, $retryCount); } } else { diff --git a/databox/api/src/Listener/AssetIngestWorkflowListener.php b/databox/api/src/Listener/AssetIngestWorkflowListener.php index 154453dfd..18727ff93 100644 --- a/databox/api/src/Listener/AssetIngestWorkflowListener.php +++ b/databox/api/src/Listener/AssetIngestWorkflowListener.php @@ -12,17 +12,16 @@ { public function __construct( private PusherManager $pusherManager, - ) - { + ) { } public function onWorkflowUpdate(WorkflowUpdateEvent $event): void { $state = $event->getState(); if (str_starts_with($state->getWorkflowName(), 'asset-ingest:') && in_array($state->getStatus(), [ - WorkflowState::STATUS_SUCCESS, - WorkflowState::STATUS_FAILURE, - ])) { + WorkflowState::STATUS_SUCCESS, + WorkflowState::STATUS_FAILURE, + ])) { $assetId = $state->getEvent()->getInputs()['assetId']; $this->pusherManager->trigger('asset-'.$assetId, 'asset_ingested', [], direct: true); } diff --git a/databox/api/src/Repository/Discussion/MessageRepository.php b/databox/api/src/Repository/Discussion/MessageRepository.php new file mode 100644 index 000000000..7750787a2 --- /dev/null +++ b/databox/api/src/Repository/Discussion/MessageRepository.php @@ -0,0 +1,39 @@ +createQueryBuilder('t') + ->addOrderBy('t.createdAt', 'DESC') + ->addOrderBy('t.id', 'ASC') + ->andWhere('t.thread = :threadId') + ->setParameter('threadId', $threadId) + ->getQuery() + ->toIterable() + ; + } + + public function getESQueryBuilder(): QueryBuilder + { + return $this + ->createQueryBuilder('t') + ->addOrderBy('t.createdAt', 'DESC') + ->addOrderBy('t.id', 'ASC') + ; + } +} diff --git a/databox/api/src/Repository/Discussion/ThreadRepository.php b/databox/api/src/Repository/Discussion/ThreadRepository.php new file mode 100644 index 000000000..273588130 --- /dev/null +++ b/databox/api/src/Repository/Discussion/ThreadRepository.php @@ -0,0 +1,27 @@ +createQueryBuilder('t') + ->andWhere('t.key = :key') + ->setParameter('key', $key) + ->getQuery() + ->getOneOrNullResult() + ; + } +} diff --git a/databox/api/src/Security/Voter/ThreadMessageVoter.php b/databox/api/src/Security/Voter/ThreadMessageVoter.php new file mode 100644 index 000000000..75ed2de20 --- /dev/null +++ b/databox/api/src/Security/Voter/ThreadMessageVoter.php @@ -0,0 +1,38 @@ +getUser(); + + return $user instanceof JwtUser && $subject->getAuthorId() === $user->getId(); + } + + return false; + } +} diff --git a/databox/api/src/Security/Voter/ThreadVoter.php b/databox/api/src/Security/Voter/ThreadVoter.php new file mode 100644 index 000000000..88e6984e9 --- /dev/null +++ b/databox/api/src/Security/Voter/ThreadVoter.php @@ -0,0 +1,46 @@ +discussionManager->getThreadObject($subject); + + switch ($attribute) { + case self::READ: + return $this->security->isGranted(self::READ, $object); + case self::EDIT: + return $this->security->isGranted(JwtUser::IS_AUTHENTICATED_FULLY) + && $this->security->isGranted(self::EDIT, $object); + } + + return false; + } +} diff --git a/databox/api/src/Service/DiscussionManager.php b/databox/api/src/Service/DiscussionManager.php new file mode 100644 index 000000000..eecf77617 --- /dev/null +++ b/databox/api/src/Service/DiscussionManager.php @@ -0,0 +1,57 @@ +getKey(); + if (!str_contains($key, ':')) { + throw new \RuntimeException(sprintf('Invalid Thread key "%s"', $key)); + } + [$objectKey, $objectId] = explode(':', $key); + $className = $this->objectMapper->getClassName($objectKey); + + $object = $this->em->find($className, $objectId); + if (null === $object) { + throw new \RuntimeException(sprintf('Object of Thread "%s" with key "%s" not found', $thread->getId(), $key)); + } + + return $object; + } + + public function getThreadOfObject(AbstractUuidEntity $object): ?Thread + { + return $this->threadRepository->getThreadOfKey( + $this->getObjectKey($object) + ); + } + + public function getObjectKey(AbstractUuidEntity $object): string + { + $objectKey = $this->objectMapper->getObjectKey($object); + + return sprintf('%s:%s', $objectKey, $object->getId()); + } + + public function getThreadMessages(string $threadId): iterable + { + return $this->messageRepository->getThreadMessages($threadId); + } +} diff --git a/databox/api/src/Service/DiscussionPusher.php b/databox/api/src/Service/DiscussionPusher.php new file mode 100644 index 000000000..64276b364 --- /dev/null +++ b/databox/api/src/Service/DiscussionPusher.php @@ -0,0 +1,36 @@ +bus->dispatch($this->pusherManager->createBusMessage( + 'thread-'.$message->getThread()->getId(), + $event, + $removed ? [ + 'id' => $message->getId(), + ] : json_decode($this->serializer->serialize($message, 'json', [ + 'groups' => [ + '_', + Message::GROUP_READ, + ], + ]), true, 512, JSON_THROW_ON_ERROR), + )); + } +} diff --git a/databox/api/src/Storage/RenditionManager.php b/databox/api/src/Storage/RenditionManager.php index bd08e97f9..824325afd 100644 --- a/databox/api/src/Storage/RenditionManager.php +++ b/databox/api/src/Storage/RenditionManager.php @@ -106,9 +106,8 @@ public function createOrReplaceRenditionFile( public function getOrCreateRendition( Asset $asset, - RenditionDefinition $definition - ): AssetRendition - { + RenditionDefinition $definition, + ): AssetRendition { if (null !== $assetRendition = $this->getAssetRenditionByDefinition($asset, $definition)) { return $assetRendition; } diff --git a/databox/api/src/Validator/ValidRenditionDefinitionConstraintValidator.php b/databox/api/src/Validator/ValidRenditionDefinitionConstraintValidator.php index 496b379f4..adab3aa68 100644 --- a/databox/api/src/Validator/ValidRenditionDefinitionConstraintValidator.php +++ b/databox/api/src/Validator/ValidRenditionDefinitionConstraintValidator.php @@ -17,12 +17,12 @@ public function __construct(private readonly YamlLoader $yamlLoader, private rea } /** - * @param string $value + * @param string $value * @param ValidRenditionDefinitionConstraint $constraint */ public function validate($value, Constraint $constraint): void { - if(!$value) { + if (!$value) { return; } try { diff --git a/databox/api/symfony.lock b/databox/api/symfony.lock index 49f8786f1..318b049ea 100644 --- a/databox/api/symfony.lock +++ b/databox/api/symfony.lock @@ -26,6 +26,9 @@ "alchemy/metadata-manipulator-bundle": { "version": "dev-PS-478-3_attribute-initializers" }, + "alchemy/notify-bundle": { + "version": "dev-PS-706-discussions" + }, "alchemy/oauth-server-bundle": { "version": "dev-ps-248-databox" }, diff --git a/databox/client/config-compiler.js b/databox/client/config-compiler.js index e506803f6..1fe992ca7 100644 --- a/databox/client/config-compiler.js +++ b/databox/client/config-compiler.js @@ -49,7 +49,9 @@ return false; } - const stackConfig = JSON.parse(require('node:fs').readFileSync('/etc/app/stack-config.json', 'utf8')); + const stackConfig = JSON.parse( + require('node:fs').readFileSync('/etc/app/stack-config.json', 'utf8') + ); const customHTML = {}; customHTML['__MUI_THEME__'] = ''; if (stackConfig.theme) { diff --git a/databox/client/package.json b/databox/client/package.json index f7fc4357b..3877d144f 100644 --- a/databox/client/package.json +++ b/databox/client/package.json @@ -21,6 +21,8 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.2", + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@mui/icons-material": "^5.16.7", @@ -37,6 +39,7 @@ "classnames": "^2.5.1", "clipboard-copy": "^4.0.1", "country-locale-map": "^1.9.8", + "emoji-mart": "^5.6.0", "flag-icons": "^6.15.0", "formik": "^2.4.6", "formik-material-ui": "4.0.0-alpha.2", @@ -65,6 +68,7 @@ "react-string-replace": "^1.1.1", "react-toastify": "^9.1.3", "react-virtualized": "^9.22.5", + "react-zoom-pan-pinch": "^3.6.1", "sass": "^1.79.4", "tui-image-editor": "^3.15.3", "uuid": "^9.0.1", @@ -79,6 +83,7 @@ "lint": "eslint src", "lint:fix": "eslint --fix src", "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json|cjs|tsx|jsx)\"", + "cs": "pnpm lint:fix && pnpm format", "preview": "vite preview", "translate": "i18next-scanner --config ../../lib/js/i18n/i18next-scanner.config.js" }, diff --git a/databox/client/src/api/asset.ts b/databox/client/src/api/asset.ts index 25c2c86bb..4572fbd35 100644 --- a/databox/client/src/api/asset.ts +++ b/databox/client/src/api/asset.ts @@ -1,6 +1,17 @@ import apiClient from './api-client'; -import {Asset, AssetFileVersion, Attribute, Collection, ESDocumentState, Share} from '../types'; -import {ApiCollectionResponse, getAssetsHydraCollection, getHydraCollection,} from './hydra'; +import { + Asset, + AssetFileVersion, + Attribute, + Collection, + ESDocumentState, + Share, +} from '../types'; +import { + ApiCollectionResponse, + getAssetsHydraCollection, + getHydraCollection, +} from './hydra'; import {AxiosRequestConfig} from 'axios'; import {TFacets} from '../components/Media/Asset/Facets'; @@ -95,11 +106,17 @@ export async function getAsset(id: string): Promise { return (await apiClient.get(`/assets/${id}`)).data; } -export async function getESDocument(entity: string, id: string): Promise { +export async function getESDocument( + entity: string, + id: string +): Promise { return (await apiClient.get(`/${entity}/${id}/es-document`)).data; } -export async function syncESDocument(entity: string, id: string): Promise { +export async function syncESDocument( + entity: string, + id: string +): Promise { await apiClient.post(`/${entity}/${id}/es-document-sync`, {}); } diff --git a/databox/client/src/api/basket.ts b/databox/client/src/api/basket.ts index 92cc3410d..e140ccba5 100644 --- a/databox/client/src/api/basket.ts +++ b/databox/client/src/api/basket.ts @@ -65,8 +65,7 @@ export async function deleteBasket(id: string): Promise { await apiClient.delete(`/baskets/${id}`); } -export type BasketAssetInput = { -} & Entity; +export type BasketAssetInput = {} & Entity; type AddToBasketInput = { assets: BasketAssetInput[]; diff --git a/databox/client/src/api/discussion.ts b/databox/client/src/api/discussion.ts new file mode 100644 index 000000000..6740bfee7 --- /dev/null +++ b/databox/client/src/api/discussion.ts @@ -0,0 +1,38 @@ +import {ApiCollectionResponse, getHydraCollection} from './hydra.ts'; +import {ThreadMessage} from '../types.ts'; +import apiClient from './api-client.ts'; + +export async function getThreadMessages( + threadId: string, + nextUrl?: string +): Promise> { + const res = await apiClient.get(nextUrl || `/threads/${threadId}/messages`); + + return getHydraCollection(res.data); +} + +export async function postThreadMessage(data: { + threadKey: string; + threadId?: string; + content: string; + attachments?: ThreadMessage['attachments']; +}): Promise { + const res = await apiClient.post(`/messages`, data); + + return res.data; +} + +export async function putThreadMessage( + id: string, + data: { + content: string; + } +): Promise { + const res = await apiClient.put(`/messages/${id}`, data); + + return res.data; +} + +export async function deleteThreadMessage(id: string): Promise { + await apiClient.delete(`/messages/${id}`); +} diff --git a/databox/client/src/api/hydra.ts b/databox/client/src/api/hydra.ts index b8a1c47ce..43a6fc87f 100644 --- a/databox/client/src/api/hydra.ts +++ b/databox/client/src/api/hydra.ts @@ -3,10 +3,10 @@ import {Asset} from '../types'; export type ApiCollectionResponse = { total: number; - first: string | null; - previous: string | null; - next: string | null; - last: string | null; + first?: string | null; + previous?: string | null; + next?: string | null; + last?: string | null; result: T[]; facets?: TFacets | undefined; } & E; diff --git a/databox/client/src/api/uploader/file.ts b/databox/client/src/api/uploader/file.ts index a38558622..bc79ce411 100644 --- a/databox/client/src/api/uploader/file.ts +++ b/databox/client/src/api/uploader/file.ts @@ -3,7 +3,7 @@ import uploaderClient from '../uploader-client'; import {promiseConcurrency} from '../../lib/promises'; import {oauthClient} from '../api-client'; import {RawAxiosRequestHeaders} from 'axios'; -import {multipartUpload} from '../../../../../lib/js/api/src/multiPartUpload.ts'; +import {multipartUpload} from '@alchemy/api/src/multiPartUpload.ts'; interface MyHeaders extends RawAxiosRequestHeaders { Authorization?: string; diff --git a/databox/client/src/components/App.tsx b/databox/client/src/components/App.tsx index 18232c7bc..bf388f7bb 100644 --- a/databox/client/src/components/App.tsx +++ b/databox/client/src/components/App.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useRef} from 'react'; import MainAppBar, {menuHeight} from './Layout/MainAppBar'; import LeftPanel from './Media/LeftPanel'; import ResultProvider from './Media/Search/ResultProvider'; @@ -12,12 +12,15 @@ import DisplayProvider from './Media/DisplayProvider'; import uploaderClient from '../api/uploader-client'; import {ZIndex} from '../themes/zIndex'; import {useRequestErrorHandler} from '@alchemy/api'; +import {useLocation} from '@alchemy/navigation'; import {setSentryUser} from '@alchemy/core'; import {useAuth} from '@alchemy/react-auth'; import AssetSearch from './AssetSearch/AssetSearch'; import {leftPanelWidth} from '../themes/base'; const AppProxy = React.memo(() => { + const location = useLocation(); + const alreadyRendered = useRef(false); const isSmallView = useMediaQuery((theme: Theme) => theme.breakpoints.down('md') ); @@ -30,6 +33,12 @@ const AppProxy = React.memo(() => { setLeftPanelOpen(!isSmallView); }, [isSmallView]); + if (location.search.includes('_m=') && !alreadyRendered.current) { + return null; + } + + alreadyRendered.current = true; + return ( diff --git a/databox/client/src/components/AssetSearch/AssetSearch.tsx b/databox/client/src/components/AssetSearch/AssetSearch.tsx index e06b07222..8e5f00e0e 100644 --- a/databox/client/src/components/AssetSearch/AssetSearch.tsx +++ b/databox/client/src/components/AssetSearch/AssetSearch.tsx @@ -10,6 +10,7 @@ import UploadModal from '../Upload/UploadModal'; import {modalRoutes} from '../../routes'; import {useNavigateToModal} from '../Routing/ModalLink'; import {OnOpen} from '../AssetList/types'; +import {AssetContextState} from '../Media/Asset/assetTypes.ts'; type Props = {}; @@ -37,13 +38,26 @@ export default function AssetSearch({}: Props) { const onOpen = useCallback( (asset, renditionId): void => { - navigateToModal(modalRoutes.assets.routes.view, { - id: asset.id, - renditionId, - }); + navigateToModal( + modalRoutes.assets.routes.view, + { + id: asset.id, + renditionId, + }, + { + state: { + assetsContext: resultContext.pages + .flat() + .map(a => [ + a.id, + a.original?.id, + ]) as AssetContextState, + }, + } + ); // eslint-disable-next-line }, - [navigateToModal] + [navigateToModal, resultContext] ); return ( diff --git a/databox/client/src/components/Dialog/Asset/AssetDialog.tsx b/databox/client/src/components/Dialog/Asset/AssetDialog.tsx index c23147782..9e9624c63 100644 --- a/databox/client/src/components/Dialog/Asset/AssetDialog.tsx +++ b/databox/client/src/components/Dialog/Asset/AssetDialog.tsx @@ -16,7 +16,7 @@ import {modalRoutes} from '../../../routes'; import {useNavigateToModal} from '../../Routing/ModalLink.tsx'; import AssetWorkflow from './AssetWorkflow.tsx'; import {useAuth} from '@alchemy/react-auth'; -import ESDocument from "./ESDocument.tsx"; +import ESDocument from './ESDocument.tsx'; type Props = {}; diff --git a/databox/client/src/components/Dialog/Asset/AssetFileVersion.tsx b/databox/client/src/components/Dialog/Asset/AssetFileVersion.tsx index 8a6ac2dcb..cb8b6ecdc 100644 --- a/databox/client/src/components/Dialog/Asset/AssetFileVersion.tsx +++ b/databox/client/src/components/Dialog/Asset/AssetFileVersion.tsx @@ -16,7 +16,7 @@ import {useTranslation} from 'react-i18next'; import DownloadIcon from '@mui/icons-material/Download'; import SaveAsButton from '../../Media/Asset/Actions/SaveAsButton'; import DateTime from '../../Ui/DateTime'; -import DeleteIcon from "@mui/icons-material/Delete"; +import DeleteIcon from '@mui/icons-material/Delete'; const cardProps = { elevation: 2, diff --git a/databox/client/src/components/Dialog/Asset/AssetFileVersions.tsx b/databox/client/src/components/Dialog/Asset/AssetFileVersions.tsx index ffcf7672e..cd2eb1d0d 100644 --- a/databox/client/src/components/Dialog/Asset/AssetFileVersions.tsx +++ b/databox/client/src/components/Dialog/Asset/AssetFileVersions.tsx @@ -8,9 +8,9 @@ import { AssetFileVersionSkeleton, } from './AssetFileVersion'; import {useTranslation} from 'react-i18next'; -import ConfirmDialog from "../../Ui/ConfirmDialog.tsx"; -import {toast} from "react-toastify"; -import {useModals} from "@alchemy/navigation"; +import ConfirmDialog from '../../Ui/ConfirmDialog.tsx'; +import {toast} from 'react-toastify'; +import {useModals} from '@alchemy/navigation'; type Props = { data: Asset; @@ -30,7 +30,6 @@ export default function AssetFileVersions({data, onClose, minHeight}: Props) { getAssetFileVersions(data.id).then(d => setVersions(d.result)); }, []); - const onDelete = async (id: string) => { openModal(ConfirmDialog, { title: t( @@ -49,7 +48,7 @@ export default function AssetFileVersions({data, onClose, minHeight}: Props) { ); }, }); - } + }; return ( = { data: T; @@ -40,11 +40,11 @@ export default function ESDocument({ const sync = async () => { setSynced(true); try { - await syncESDocument(entity, data.id) + await syncESDocument(entity, data.id); } catch (e) { setSynced(false); } - } + }; return ( ({ disableGutters onClose={onClose} minHeight={minHeight} - actions={<> - } - > - {t('es_document.refresh', 'Refresh')} - - } + actions={ + <> + } + > + {t('es_document.refresh', 'Refresh')} + + + } > - - {document ? <> - {!document.synced ? - {synced ? t('es_document.sync_scheduled', 'Sync scheduled') : t('asset.es_document.sync_now', 'Sync Now')} - } - > - {t('es_document.not_synced', 'This document is not synced.')} - : null} -
-                    {JSON.stringify(document.data, null, 4)}
-                
- : null} + {document ? ( + <> + {!document.synced ? ( + + {synced + ? t( + 'es_document.sync_scheduled', + 'Sync scheduled' + ) + : t( + 'asset.es_document.sync_now', + 'Sync Now' + )} + + } + > + {t( + 'es_document.not_synced', + 'This document is not synced.' + )} + + ) : null} +
+                        {JSON.stringify(document.data, null, 4)}
+                    
+ + ) : null}
); } diff --git a/databox/client/src/components/Dialog/Asset/InfoAsset.tsx b/databox/client/src/components/Dialog/Asset/InfoAsset.tsx index 27481a184..b2970a385 100644 --- a/databox/client/src/components/Dialog/Asset/InfoAsset.tsx +++ b/databox/client/src/components/Dialog/Asset/InfoAsset.tsx @@ -9,8 +9,8 @@ import InfoRow from '../Info/InfoRow'; import {useTranslation} from 'react-i18next'; import BusinessIcon from '@mui/icons-material/Business'; import FolderIcon from '@mui/icons-material/Folder'; -import {useNavigateToModal} from "../../Routing/ModalLink.tsx"; -import {modalRoutes} from "../../../routes.ts"; +import {useNavigateToModal} from '../../Routing/ModalLink.tsx'; +import {modalRoutes} from '../../../routes.ts'; type Props = { data: Asset; @@ -80,12 +80,19 @@ export default function InfoAsset({data, onClose, minHeight}: Props) { t('asset.info.collection.none', 'None') } copyValue={data.referenceCollection?.id} - onClick={data.referenceCollection ? () => { - navigateToModal(modalRoutes.collections.routes.manage, { - id: data.referenceCollection!.id, - tab: 'info', - }); - } : undefined} + onClick={ + data.referenceCollection + ? () => { + navigateToModal( + modalRoutes.collections.routes.manage, + { + id: data.referenceCollection!.id, + tab: 'info', + } + ); + } + : undefined + } />
diff --git a/databox/client/src/components/Dialog/Asset/OperationsAsset.tsx b/databox/client/src/components/Dialog/Asset/OperationsAsset.tsx index 573a561ad..21f53520c 100644 --- a/databox/client/src/components/Dialog/Asset/OperationsAsset.tsx +++ b/databox/client/src/components/Dialog/Asset/OperationsAsset.tsx @@ -12,9 +12,9 @@ import { } from '@mui/material'; import {deleteAsset, deleteAssetShortcut, getAsset} from '../../../api/asset'; import {Trans, useTranslation} from 'react-i18next'; -import {FormSection} from '../../../../../../lib/js/react-form'; +import {FormSection} from '@alchemy/react-form'; import ConfirmDialog from '../../Ui/ConfirmDialog.tsx'; -import {useModals} from '../../../../../../lib/js/navigation'; +import {useModals} from '@alchemy/navigation'; import ShortcutIcon from '@mui/icons-material/Shortcut'; import {CollectionChip, WorkspaceChip} from '../../Ui/Chips.tsx'; diff --git a/databox/client/src/components/Dialog/Asset/Rendition.tsx b/databox/client/src/components/Dialog/Asset/Rendition.tsx index 3253b6490..5eb9711ff 100644 --- a/databox/client/src/components/Dialog/Asset/Rendition.tsx +++ b/databox/client/src/components/Dialog/Asset/Rendition.tsx @@ -2,15 +2,15 @@ import React from 'react'; import {Asset, AssetRendition} from '../../../types'; import FilePlayer from '../../Media/Asset/FilePlayer'; import {Dimensions} from '../../Media/Asset/Players'; -import {Box, Button, Chip, Tooltip,} from '@mui/material'; +import {Box, Button, Chip, Tooltip} from '@mui/material'; import byteSize from 'byte-size'; import DownloadIcon from '@mui/icons-material/Download'; import SaveAsButton from '../../Media/Asset/Actions/SaveAsButton'; import {useTranslation} from 'react-i18next'; -import DeleteIcon from "@mui/icons-material/Delete"; -import {RenditionStructure} from "./RenditionStructure.tsx"; -import {LoadingButton} from "@mui/lab"; -import LockIcon from "@mui/icons-material/Lock"; +import DeleteIcon from '@mui/icons-material/Delete'; +import {RenditionStructure} from './RenditionStructure.tsx'; +import {LoadingButton} from '@mui/lab'; +import LockIcon from '@mui/icons-material/Lock'; import AspectRatioIcon from '@mui/icons-material/AspectRatio'; import CropRotateIcon from '@mui/icons-material/CropRotate'; import ChangeCircleIcon from '@mui/icons-material/ChangeCircle'; @@ -40,32 +40,68 @@ export function Rendition({ } finally { setDeleting(false); } - } + }; return ( -
{name}
- {locked && } - {substituted && } - {undefined !== projection && <> - {projection ? - : } - } - } + title={ + +
{name}
+ {locked && ( + + + + )} + {substituted && ( + + + + )} + {undefined !== projection && ( + <> + {projection ? ( + + + + ) : ( + + + + )} + + )} +
+ } dimensions={dimensions} media={ file ? ( @@ -126,7 +162,7 @@ export function Rendition({ disabled={deleting} onClick={deleteRendition} color={'error'} - startIcon={} + startIcon={} > {t('renditions.delete', 'Delete')} @@ -135,4 +171,3 @@ export function Rendition({ /> ); } - diff --git a/databox/client/src/components/Dialog/Asset/RenditionSkeleton.tsx b/databox/client/src/components/Dialog/Asset/RenditionSkeleton.tsx index 57f88d13c..fde0abf42 100644 --- a/databox/client/src/components/Dialog/Asset/RenditionSkeleton.tsx +++ b/databox/client/src/components/Dialog/Asset/RenditionSkeleton.tsx @@ -1,16 +1,16 @@ -import {Dimensions} from "../../Media/Asset/Players"; -import {RenditionStructure} from "./RenditionStructure.tsx"; -import {Skeleton} from "@mui/material"; +import {Dimensions} from '../../Media/Asset/Players'; +import {RenditionStructure} from './RenditionStructure.tsx'; +import {Skeleton} from '@mui/material'; -export function RenditionSkeleton({dimensions}: { dimensions: Dimensions }) { +export function RenditionSkeleton({dimensions}: {dimensions: Dimensions}) { return ( } - info={} + title={} + info={} dimensions={dimensions} - media={} + media={} actions={ - + } /> ); diff --git a/databox/client/src/components/Dialog/Asset/RenditionStructure.tsx b/databox/client/src/components/Dialog/Asset/RenditionStructure.tsx index ea25aceeb..a0718512d 100644 --- a/databox/client/src/components/Dialog/Asset/RenditionStructure.tsx +++ b/databox/client/src/components/Dialog/Asset/RenditionStructure.tsx @@ -1,6 +1,12 @@ -import {ReactNode} from "react"; -import {Dimensions} from "../../Media/Asset/Players"; -import {Card, CardActions, CardContent, CardMedia, Typography} from "@mui/material"; +import {ReactNode} from 'react'; +import {Dimensions} from '../../Media/Asset/Players'; +import { + Card, + CardActions, + CardContent, + CardMedia, + Typography, +} from '@mui/material'; type Props = { title: ReactNode; @@ -36,9 +42,11 @@ export function RenditionStructure({ > {media ? media : ''} - + {title} diff --git a/databox/client/src/components/Dialog/Asset/Renditions.tsx b/databox/client/src/components/Dialog/Asset/Renditions.tsx index 6910fb981..5b882f7a0 100644 --- a/databox/client/src/components/Dialog/Asset/Renditions.tsx +++ b/databox/client/src/components/Dialog/Asset/Renditions.tsx @@ -4,10 +4,10 @@ import {DialogTabProps} from '../Tabbed/TabbedDialog'; import ContentTab from '../Tabbed/ContentTab'; import {deleteRendition, getAssetRenditions} from '../../../api/rendition'; import {Rendition} from './Rendition'; -import {RenditionSkeleton} from "./RenditionSkeleton.tsx"; -import ConfirmDialog from "../../Ui/ConfirmDialog.tsx"; -import {toast} from "react-toastify"; -import {useModals} from "@alchemy/navigation"; +import {RenditionSkeleton} from './RenditionSkeleton.tsx'; +import ConfirmDialog from '../../Ui/ConfirmDialog.tsx'; +import {toast} from 'react-toastify'; +import {useModals} from '@alchemy/navigation'; import {useTranslation} from 'react-i18next'; type Props = { @@ -26,7 +26,7 @@ export default function Renditions({data, onClose, minHeight}: Props) { useEffect(() => { getAssetRenditions(data.id).then(d => setRenditions(d.result)); - }, []); + }, [data.id]); const onDelete = async (id: string) => { openModal(ConfirmDialog, { @@ -45,7 +45,7 @@ export default function Renditions({data, onClose, minHeight}: Props) { ); }, }); - } + }; return ( setIntegrations(r.result)); - }, []); + }, [data.id]); const components: Partial< Record> diff --git a/databox/client/src/components/Dialog/Collection/CollectionDialog.tsx b/databox/client/src/components/Dialog/Collection/CollectionDialog.tsx index c5e24668f..ef6c64159 100644 --- a/databox/client/src/components/Dialog/Collection/CollectionDialog.tsx +++ b/databox/client/src/components/Dialog/Collection/CollectionDialog.tsx @@ -12,7 +12,7 @@ import Operations from './Operations'; import InfoCollection from './InfoCollection'; import {modalRoutes} from '../../../routes'; import {useCloseModal} from '../../Routing/ModalLink'; -import ESDocument from "../Asset/ESDocument.tsx"; +import ESDocument from '../Asset/ESDocument.tsx'; import {useAuth} from '@alchemy/react-auth'; type Props = {}; diff --git a/databox/client/src/components/Dialog/Collection/InfoCollection.tsx b/databox/client/src/components/Dialog/Collection/InfoCollection.tsx index 6ec45c4c6..f6d9cedcb 100644 --- a/databox/client/src/components/Dialog/Collection/InfoCollection.tsx +++ b/databox/client/src/components/Dialog/Collection/InfoCollection.tsx @@ -9,8 +9,8 @@ import PersonIcon from '@mui/icons-material/Person'; import {useTranslation} from 'react-i18next'; import FolderIcon from '@mui/icons-material/Folder'; import BusinessIcon from '@mui/icons-material/Business'; -import {useNavigateToModal} from "../../Routing/ModalLink.tsx"; -import {modalRoutes} from "../../../routes.ts"; +import {useNavigateToModal} from '../../Routing/ModalLink.tsx'; +import {modalRoutes} from '../../../routes.ts'; type Props = { id: string; diff --git a/databox/client/src/components/Dialog/Info/InfoRow.tsx b/databox/client/src/components/Dialog/Info/InfoRow.tsx index acac8a0a8..cb5d584a9 100644 --- a/databox/client/src/components/Dialog/Info/InfoRow.tsx +++ b/databox/client/src/components/Dialog/Info/InfoRow.tsx @@ -1,5 +1,12 @@ import {ReactNode} from 'react'; -import {Box, IconButton, ListItemIcon, ListItemText, MenuItem, Typography,} from '@mui/material'; +import { + Box, + IconButton, + ListItemIcon, + ListItemText, + MenuItem, + Typography, +} from '@mui/material'; import CopyToClipboard from '../../../lib/CopyToClipboard'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; @@ -11,7 +18,13 @@ type Props = { onClick?: () => void; }; -export default function InfoRow({icon, label, value, copyValue, onClick}: Props) { +export default function InfoRow({ + icon, + label, + value, + copyValue, + onClick, +}: Props) { return ( {icon && {icon}} @@ -33,17 +46,13 @@ export default function InfoRow({icon, label, value, copyValue, onClick}: Props) copy(copyValue); }} > - + )} )} - {onClick ? - {value} - : value} + {onClick ? {value} : value} ); diff --git a/databox/client/src/components/Dialog/Workspace/Acl.tsx b/databox/client/src/components/Dialog/Workspace/Acl.tsx index 68c3c1c1e..ab092fc13 100644 --- a/databox/client/src/components/Dialog/Workspace/Acl.tsx +++ b/databox/client/src/components/Dialog/Workspace/Acl.tsx @@ -19,9 +19,9 @@ export default function Acl({data, onClose, minHeight}: Props) { p !== AclPermission.SHARE - ).concat([AclPermission.ALL])} + displayedPermissions={Object.keys(aclPermissions) + .filter(p => p !== AclPermission.SHARE) + .concat([AclPermission.ALL])} /> ); diff --git a/databox/client/src/components/Discussion/Attachments.tsx b/databox/client/src/components/Discussion/Attachments.tsx new file mode 100644 index 000000000..138fc68f1 --- /dev/null +++ b/databox/client/src/components/Discussion/Attachments.tsx @@ -0,0 +1,42 @@ +import {DeserializedMessageAttachment} from '../../types.ts'; +import {Box, Chip} from '@mui/material'; + +type Props = { + attachments: DeserializedMessageAttachment[]; + onDelete?: (attachment: DeserializedMessageAttachment) => void; + onClick?: (attachment: DeserializedMessageAttachment) => void; +}; + +export default function Attachments({attachments, onDelete, onClick}: Props) { + return ( + *': { + display: 'inline-block', + mt: 1, + mr: 1, + }, + }} + > + {attachments?.map((attachment, index) => { + return ( +
+ onClick(attachment) : undefined + } + onDelete={ + onDelete + ? () => onDelete(attachment) + : undefined + } + /> +
+ ); + })} +
+ ); +} diff --git a/databox/client/src/components/Discussion/DiscussionMessage.tsx b/databox/client/src/components/Discussion/DiscussionMessage.tsx new file mode 100644 index 000000000..d201ed750 --- /dev/null +++ b/databox/client/src/components/Discussion/DiscussionMessage.tsx @@ -0,0 +1,171 @@ +import {ThreadMessage} from '../../types.ts'; +import { + Box, + Divider, + ListItemIcon, + ListItemText, + MenuItem, + Typography, +} from '@mui/material'; +import moment from 'moment'; +import {OnActiveAnnotations} from '../Media/Asset/Attribute/Attributes.tsx'; +import {AssetAnnotation} from '../Media/Asset/Annotations/annotationTypes.ts'; +import Attachments from './Attachments.tsx'; +import {FlexRow, MoreActionsButton, UserAvatar} from '@alchemy/phrasea-ui'; +import {useTranslation} from 'react-i18next'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import EditMessage from './EditMessage.tsx'; +import React from 'react'; +import nl2br from 'react-nl2br'; + +type Props = { + message: ThreadMessage; + onActiveAnnotations?: OnActiveAnnotations | undefined; + onDelete: (message: ThreadMessage) => void; + onEdit: (message: ThreadMessage) => void; +}; + +export default function DiscussionMessage({ + message, + onActiveAnnotations, + onDelete, + onEdit, +}: Props) { + const m = moment(message.createdAt); + const {t} = useTranslation(); + const [editing, setEditing] = React.useState(false); + + return ( + <> + + + + +
+
+ +
+ {message.author.username} + + {' - '} + + {m.calendar()} + + +
+ + + {closeWrapper => [ + message.capabilities?.canEdit ? ( + { + setEditing(true); + })} + > + + + + + + ) : null, + message.capabilities?.canDelete ? ( + { + onDelete(message); + })} + > + + + + + + ) : null, + ]} + +
+
+ + <> + {editing ? ( + { + setEditing(false); + onEdit(message); + }} + onCancel={() => { + setEditing(false); + }} + /> + ) : ( + <> + + {nl2br(message.content)} + + + {message.attachments ? ( + { + if ( + onActiveAnnotations && + attachment.type === 'annotation' + ) { + onActiveAnnotations([ + attachment.data as AssetAnnotation, + ]); + } + }} + attachments={message.attachments.map( + a => ({ + data: JSON.parse(a.content), + type: a.type, + }) + )} + /> + ) : null} + + )} + +
+
+ + + ); +} diff --git a/databox/client/src/components/Discussion/EditMessage.tsx b/databox/client/src/components/Discussion/EditMessage.tsx new file mode 100644 index 000000000..33c622113 --- /dev/null +++ b/databox/client/src/components/Discussion/EditMessage.tsx @@ -0,0 +1,56 @@ +import {useTranslation} from 'react-i18next'; +import {useFormSubmit} from '@alchemy/api'; +import {useFormPrompt} from '@alchemy//navigation'; +import {putThreadMessage} from '../../api/discussion.ts'; +import {ThreadMessage} from '../../types.ts'; +import React from 'react'; +import MessageField, {MessageFormData} from './MessageField.tsx'; + +type Props = { + data: ThreadMessage; + onEdit: (message: ThreadMessage) => void; + onCancel: () => void; +}; + +export default function EditMessage({data, onEdit, onCancel}: Props) { + const {t} = useTranslation(); + const inputRef = React.useRef(null); + + const useFormSubmitProps = useFormSubmit({ + defaultValues: data, + onSubmit: async (formData: MessageFormData) => { + return await putThreadMessage(data.id, { + content: formData.content, + }); + }, + onSuccess: (data: ThreadMessage) => { + onEdit(data); + }, + }); + + React.useEffect(() => { + inputRef.current?.focus(); + }, []); + + const {handleSubmit, forbidNavigation} = useFormSubmitProps; + + useFormPrompt(t, forbidNavigation); + + return ( + <> +
+ + + + ); +} diff --git a/databox/client/src/components/Discussion/EmojiPicker.tsx b/databox/client/src/components/Discussion/EmojiPicker.tsx new file mode 100644 index 000000000..71aedc15d --- /dev/null +++ b/databox/client/src/components/Discussion/EmojiPicker.tsx @@ -0,0 +1,55 @@ +import data from '@emoji-mart/data'; +import Picker from '@emoji-mart/react'; +import React from 'react'; +import {ClickAwayListener, IconButton, Popover} from '@mui/material'; +import EmojiEmotionsIcon from '@mui/icons-material/EmojiEmotions'; +import {stopPropagation} from '../../lib/stdFuncs.ts'; + +type Props = { + onSelect?: (emoji: string) => void; +}; + +export default function EmojiPicker({onSelect}: Props) { + const [anchor, setAnchor] = React.useState(null); + const close = () => setAnchor(null); + + const open = Boolean(anchor); + + return ( + <> + { + e.stopPropagation(); + setAnchor(p => + p ? null : (e.target as HTMLButtonElement) + ); + }} + > + + + + + +
+ { + onSelect?.(e.native); + close(); + }} + previewPosition={'none'} + /> +
+
+
+ + ); +} diff --git a/databox/client/src/components/Discussion/MessageField.tsx b/databox/client/src/components/Discussion/MessageField.tsx new file mode 100644 index 000000000..8904da815 --- /dev/null +++ b/databox/client/src/components/Discussion/MessageField.tsx @@ -0,0 +1,134 @@ +import {Box, Button, InputBase} from '@mui/material'; +import {LoadingButton} from '@mui/lab'; +import SendIcon from '@mui/icons-material/Send'; +import React from 'react'; +import RemoteErrors from '../Form/RemoteErrors.tsx'; +import Attachments from './Attachments.tsx'; +import { + DeserializedMessageAttachment, + StateSetter, + ThreadMessage, +} from '../../types.ts'; +import {FormFieldErrors, FormRow} from '@alchemy/react-form'; +import type {UseFormSubmitReturn} from '@alchemy/api'; +import {FlexRow} from '@alchemy/phrasea-ui'; +import EmojiPicker from './EmojiPicker.tsx'; + +export type MessageFormData = Pick; + +type Props = { + submitLabel: string; + placeholder: string; + inputRef: React.MutableRefObject; + attachments?: DeserializedMessageAttachment[]; + setAttachments?: StateSetter; + useFormSubmitProps: UseFormSubmitReturn; + onCancel?: () => void; + cancelButtonLabel?: string; +}; + +export default function MessageField({ + submitLabel, + inputRef, + attachments, + setAttachments, + useFormSubmitProps, + placeholder, + onCancel, + cancelButtonLabel, +}: Props) { + const { + formState: {errors}, + remoteErrors, + submitting, + register, + } = useFormSubmitProps; + + const {ref, ...rest} = register('content', { + required: true, + }); + + return ( + <> + + { + return { + border: `1px solid ${theme.palette.divider}`, + borderRadius: Math.min( + theme.shape.borderRadius / 4, + 1 + ), + alignItems: 'center', + }; + }} + onClick={() => inputRef.current?.focus()} + > + { + ref(r); + inputRef.current = r; + }} + /> + {attachments ? ( + { + setAttachments!(p => + p.filter(att => att !== a) + ); + }} + /> + ) : null} + +
+ { + inputRef.current?.focus(); + document.execCommand( + 'insertText', + false, + emoji + ); + }} + /> +
+ +
+ {onCancel ? ( + + ) : null} + } + > + {submitLabel} + +
+
+
+ +
+ + + ); +} diff --git a/databox/client/src/components/Discussion/MessageForm.tsx b/databox/client/src/components/Discussion/MessageForm.tsx new file mode 100644 index 000000000..fcba97662 --- /dev/null +++ b/databox/client/src/components/Discussion/MessageForm.tsx @@ -0,0 +1,146 @@ +import {useTranslation} from 'react-i18next'; +import {useFormSubmit} from '@alchemy/api'; +import {useFormPrompt} from '@alchemy//navigation'; +import {postThreadMessage} from '../../api/discussion.ts'; +import {DeserializedMessageAttachment, ThreadMessage} from '../../types.ts'; +import React, {useCallback} from 'react'; +import { + AnnotationType, + AssetAnnotation, + OnNewAnnotationRef, +} from '../Media/Asset/Annotations/annotationTypes.ts'; +import {OnActiveAnnotations} from '../Media/Asset/Attribute/Attributes.tsx'; +import MessageField, {MessageFormData} from './MessageField.tsx'; + +type Props = { + threadKey: string; + threadId?: string; + onNewMessage: (message: ThreadMessage) => void; + onNewAnnotationRef?: OnNewAnnotationRef; + onActiveAnnotations: OnActiveAnnotations | undefined; +}; + +export default function MessageForm({ + threadKey, + threadId, + onNewMessage, + onNewAnnotationRef, + onActiveAnnotations, +}: Props) { + const {t} = useTranslation(); + const inputRef = React.useRef(null); + const [attachments, setAttachments] = React.useState< + DeserializedMessageAttachment[] + >([]); + + React.useEffect(() => { + if (onNewAnnotationRef) { + onNewAnnotationRef.current = (annotation: AssetAnnotation) => { + inputRef.current?.focus(); + + const annotationTypes: Record = { + [AnnotationType.Draw]: t('annotation.type.draw', 'Draw'), + [AnnotationType.Highlight]: t( + 'annotation.type.highlight', + 'Highlight' + ), + [AnnotationType.Cue]: t('annotation.type.cue', 'Cue'), + [AnnotationType.Circle]: t( + 'annotation.type.circle', + 'Circle' + ), + [AnnotationType.Rect]: t( + 'annotation.type.rectangle', + 'Rectangle' + ), + [AnnotationType.Point]: t('annotation.type.point', 'Point'), + [AnnotationType.TimeRange]: t( + 'annotation.type.timerange', + 'Time Range' + ), + }; + + setAttachments(p => + p.concat({ + type: 'annotation', + data: { + ...annotation, + name: + annotation.name ?? + t('form.annotation.default_name', { + defaultValue: '{{type}} #{{n}}', + type: annotationTypes[annotation.type], + n: + p.filter( + a => a.type === annotation.type + ).length + 1, + }), + }, + }) + ); + }; + } + }, [onNewAnnotationRef, inputRef]); + + const onFocus = useCallback(() => { + if (onActiveAnnotations) { + const assetAnnotations = attachments + .filter(a => a.type === 'annotation') + .map(a => a.data as AssetAnnotation); + if (assetAnnotations.length > 0) { + onActiveAnnotations(assetAnnotations); + } + } + }, [attachments, onActiveAnnotations]); + + React.useEffect(() => { + inputRef.current?.addEventListener('focus', onFocus); + onFocus(); + + return () => { + inputRef.current?.removeEventListener('focus', onFocus); + }; + }, [onFocus, inputRef]); + + const useFormSubmitProps = useFormSubmit({ + defaultValues: { + content: '', + }, + onSubmit: async (data: MessageFormData) => { + return await postThreadMessage({ + threadId, + threadKey, + content: data.content, + attachments: attachments.map(({data, ...rest}) => ({ + ...rest, + content: JSON.stringify(data), + })), + }); + }, + onSuccess: (data: ThreadMessage) => { + onNewMessage(data); + setAttachments([]); + reset(); + }, + }); + + const {forbidNavigation, handleSubmit, reset} = useFormSubmitProps; + + useFormPrompt(t, forbidNavigation); + + return ( +
+ + + ); +} diff --git a/databox/client/src/components/Discussion/Thread.tsx b/databox/client/src/components/Discussion/Thread.tsx new file mode 100644 index 000000000..05678a15a --- /dev/null +++ b/databox/client/src/components/Discussion/Thread.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import {ThreadMessage} from '../../types.ts'; +import {deleteThreadMessage, getThreadMessages} from '../../api/discussion.ts'; +import {ApiCollectionResponse} from '../../api/hydra.ts'; +import MessageForm from './MessageForm.tsx'; +import {CircularProgress} from '@mui/material'; +import DiscussionMessage from './DiscussionMessage.tsx'; +import {useChannelRegistration} from '../../lib/pusher.ts'; +import {OnActiveAnnotations} from '../Media/Asset/Attribute/Attributes.tsx'; +import {OnNewAnnotationRef} from '../Media/Asset/Annotations/annotationTypes.ts'; +import ConfirmDialog from '../Ui/ConfirmDialog.tsx'; +import {toast} from 'react-toastify'; +import {useModals} from '@alchemy/navigation'; + +import {useTranslation} from 'react-i18next'; +type Props = { + threadKey: string; + threadId?: string; + onActiveAnnotations: OnActiveAnnotations | undefined; + onNewAnnotationRef?: OnNewAnnotationRef; +}; + +export default function Thread({ + threadKey, + threadId, + onActiveAnnotations, + onNewAnnotationRef, +}: Props) { + const [messages, setMessages] = + React.useState>(); + const {openModal} = useModals(); + const {t} = useTranslation(); + + const appendMessage = React.useCallback( + (message: ThreadMessage) => { + message.acknowledged = true; + + setMessages(p => + p + ? { + ...p, + result: p.result.some(m => m.id === message.id) + ? p.result.map(m => + m.id === message.id ? message : m + ) + : p.result.concat(message), + total: p.total + 1, + } + : { + result: [message], + total: 1, + } + ); + }, + [setMessages] + ); + + const deleteMessage = React.useCallback( + (id: string) => { + setMessages(p => + p + ? { + ...p, + result: p.result.filter(m => m.id !== id), + total: p.total - 1, + } + : undefined + ); + }, + [setMessages] + ); + + React.useEffect(() => { + setMessages(undefined); + if (threadId) { + getThreadMessages(threadId).then(res => { + setMessages(res); + }); + } + }, [threadId]); + + useChannelRegistration( + `thread-${threadId}`, + `message`, + data => { + appendMessage(data); + }, + !!threadId + ); + + useChannelRegistration( + `thread-${threadId}`, + `message-delete`, + data => { + deleteMessage(data.id); + }, + !!threadId + ); + + const onDeleteMessage = (message: ThreadMessage): void => { + openModal(ConfirmDialog, { + title: t( + 'thread.message.delete.confirm.title', + 'Are you sure you want to delete this message?' + ), + onConfirm: async () => { + await deleteThreadMessage(message.id); + + setMessages(p => + p + ? { + ...p, + result: p.result.filter(m => m.id !== message.id), + total: p.total - 1, + } + : undefined + ); + + onActiveAnnotations?.([]); + + toast.success( + t( + 'thread.message.delete.confirm.success', + 'Message has been removed!' + ) as string + ); + }, + }); + }; + + const onEditMessage = (message: ThreadMessage): void => { + setMessages(p => + p + ? { + ...p, + result: p.result.map(m => + m.id === message.id ? message : m + ), + } + : undefined + ); + }; + + if (threadId && !messages) { + return ; + } + + return ( + <> + {messages?.result.map(message => ( + + ))} + + { + appendMessage(message); + }} + /> + + ); +} diff --git a/databox/client/src/components/Integration/AwsRekognition/AwsRekognitionAssetEditorActions.tsx b/databox/client/src/components/Integration/AwsRekognition/AwsRekognitionAssetEditorActions.tsx index 54ddf0a20..dde8ca511 100644 --- a/databox/client/src/components/Integration/AwsRekognition/AwsRekognitionAssetEditorActions.tsx +++ b/databox/client/src/components/Integration/AwsRekognition/AwsRekognitionAssetEditorActions.tsx @@ -10,7 +10,7 @@ import { Tooltip, } from '@mui/material'; import {runIntegrationAction} from '../../../api/integrations'; -import {IntegrationOverlayCommonProps} from '../../Media/Asset/AssetView'; +import {IntegrationOverlayCommonProps} from '../../Media/Asset/View/AssetView.tsx'; import VisibilityIcon from '@mui/icons-material/Visibility'; import ImageSearchIcon from '@mui/icons-material/ImageSearch'; import {WorkspaceIntegration} from '../../../types'; diff --git a/databox/client/src/components/Integration/Phrasea/Expose/exposeType.ts b/databox/client/src/components/Integration/Phrasea/Expose/exposeType.ts index 9c18247e2..f7b06b62d 100644 --- a/databox/client/src/components/Integration/Phrasea/Expose/exposeType.ts +++ b/databox/client/src/components/Integration/Phrasea/Expose/exposeType.ts @@ -1,4 +1,4 @@ -import {Entity} from "../../../../types.ts"; +import {Entity} from '../../../../types.ts'; export type ExposePublication = { title: string; diff --git a/databox/client/src/components/Integration/RemoveBG/RemoveBGAssetEditorActions.tsx b/databox/client/src/components/Integration/RemoveBG/RemoveBGAssetEditorActions.tsx index 5674a7e54..b06000683 100644 --- a/databox/client/src/components/Integration/RemoveBG/RemoveBGAssetEditorActions.tsx +++ b/databox/client/src/components/Integration/RemoveBG/RemoveBGAssetEditorActions.tsx @@ -2,7 +2,7 @@ import {useEffect, useState} from 'react'; import {Button, Typography} from '@mui/material'; import {ObjectType, runIntegrationAction} from '../../../api/integrations'; import ReactCompareImage from 'react-compare-image'; -import {IntegrationOverlayCommonProps} from '../../Media/Asset/AssetView'; +import {IntegrationOverlayCommonProps} from '../../Media/Asset/View/AssetView.tsx'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import IntegrationPanelContent from '../Common/IntegrationPanelContent'; import SaveAsButton from '../../Media/Asset/Actions/SaveAsButton'; diff --git a/databox/client/src/components/Integration/TuiPhotoEditor/TUIPhotoEditor.tsx b/databox/client/src/components/Integration/TuiPhotoEditor/TUIPhotoEditor.tsx index 1574f5112..e88d599d4 100644 --- a/databox/client/src/components/Integration/TuiPhotoEditor/TUIPhotoEditor.tsx +++ b/databox/client/src/components/Integration/TuiPhotoEditor/TUIPhotoEditor.tsx @@ -1,7 +1,7 @@ import React, {useEffect, useRef, useState} from 'react'; import IntegrationPanelContent from '../Common/IntegrationPanelContent'; import {} from '../../Media/Asset/FileIntegrations'; -import {IntegrationOverlayCommonProps} from '../../Media/Asset/AssetView'; +import {IntegrationOverlayCommonProps} from '../../Media/Asset/View/AssetView.tsx'; import 'tui-image-editor/dist/tui-image-editor.css'; // @ts-expect-error TS error in package import ImageEditor from '@toast-ui/react-image-editor'; diff --git a/databox/client/src/components/Integration/types.ts b/databox/client/src/components/Integration/types.ts index c571de7d5..8882a97f0 100644 --- a/databox/client/src/components/Integration/types.ts +++ b/databox/client/src/components/Integration/types.ts @@ -1,5 +1,5 @@ import {Asset, Basket, File, WorkspaceIntegration} from '../../types.ts'; -import {SetIntegrationOverlayFunction} from '../Media/Asset/AssetView.tsx'; +import {SetIntegrationOverlayFunction} from '../Media/Asset/View/AssetView.tsx'; export enum Integration { RemoveBg = 'remove.bg', diff --git a/databox/client/src/components/Layout/MainAppBar.tsx b/databox/client/src/components/Layout/MainAppBar.tsx index 8cb15128e..970dc1bd2 100644 --- a/databox/client/src/components/Layout/MainAppBar.tsx +++ b/databox/client/src/components/Layout/MainAppBar.tsx @@ -79,11 +79,15 @@ export default function MainAppBar({onToggleLeftPanel}: Props) { cursor: 'pointer', }} > - { - config.logo ? {t('common.databox', - : t('common.databox', `Databox`) - } - + {config.logo ? ( + {t('common.databox', + ) : ( + t('common.databox', `Databox`) + )} - {user ? - - : null} + {user ? ( + + + + ) : null}
{!user ? ( diff --git a/databox/client/src/components/Media/Asset/Actions/AssetViewActions.tsx b/databox/client/src/components/Media/Asset/Actions/AssetViewActions.tsx index 6deb2b2a6..9abaa6735 100644 --- a/databox/client/src/components/Media/Asset/Actions/AssetViewActions.tsx +++ b/databox/client/src/components/Media/Asset/Actions/AssetViewActions.tsx @@ -35,89 +35,100 @@ export default function AssetViewActions({asset, file}: Props) { sx={{ 'zIndex': 1, 'position': 'relative', - 'display': 'inline-block', 'ml': 2, + 'display': 'flex', + 'flexDirection': 'row', '> * + *': { ml: 1, }, }} > {can.download ? ( - +
+ +
) : ( '' )} {can.edit ? ( - } - actions={[ - { - id: 'edit_attrs', - label: t( - 'asset_actions.edit_attributes', - 'Edit attributes' - ), - onClick: onEditAttr, - disabled: !can.editAttributes, - startIcon: , - }, - { - id: 'substitute', - label: t( - 'asset_actions.substitute_file', - 'Substitute File' - ), - onClick: onSubstituteFile, - disabled: !can.substitute, - startIcon: , - }, - ]} - > - {t('asset_actions.edit', 'Edit')} - +
+ } + actions={[ + { + id: 'edit_attrs', + label: t( + 'asset_actions.edit_attributes', + 'Edit attributes' + ), + onClick: onEditAttr, + disabled: !can.editAttributes, + startIcon: , + }, + { + id: 'substitute', + label: t( + 'asset_actions.substitute_file', + 'Substitute File' + ), + onClick: onSubstituteFile, + disabled: !can.substitute, + startIcon: , + }, + ]} + > + {t('asset_actions.edit', 'Edit')} + +
) : ( '' )} {file && can.edit ? ( - +
+ +
) : ( '' )} {can.share ? ( - +
+ +
) : ( '' )} {can.delete ? ( - +
+ +
) : ( '' )} diff --git a/databox/client/src/components/Media/Asset/Actions/SubstituteFileDialog.tsx b/databox/client/src/components/Media/Asset/Actions/SubstituteFileDialog.tsx deleted file mode 100644 index a251f2656..000000000 --- a/databox/client/src/components/Media/Asset/Actions/SubstituteFileDialog.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {useState} from 'react'; -import {useTranslation} from 'react-i18next'; -import {Asset} from '../../../../types'; -import {Typography} from '@mui/material'; -import FormDialog from '../../../Dialog/FormDialog'; -import {StackedModalProps, useModals} from '@alchemy/navigation'; -import UploadDropzone from '../../../Upload/UploadDropzone.tsx'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; - -type Props = { - asset: Asset; -} & StackedModalProps; - -export default function SubstituteFileDialog({open, modalIndex}: Props) { - const {t} = useTranslation(); - const [loading, setLoading] = useState(false); - const {closeModal} = useModals(); - - const onDrop = (_files: File[]) => { - setLoading(true); - try { - // TODO - closeModal(); - } finally { - setLoading(false); - } - }; - - return ( - } - submitLabel={t('substitute_file.dialog.submit', 'Substitute')} - > - - {t('substitute_file.dialog.intro', 'Drop Here')} - - - - ); -} diff --git a/databox/client/src/components/Media/Asset/Annotations/AnnotateToolbar.tsx b/databox/client/src/components/Media/Asset/Annotations/AnnotateToolbar.tsx new file mode 100644 index 000000000..20e621679 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/AnnotateToolbar.tsx @@ -0,0 +1,134 @@ +import {IconButton, TextField} from '@mui/material'; +import {AnnotationOptions, AnnotationType} from './annotationTypes.ts'; +import MyLocationIcon from '@mui/icons-material/MyLocation'; +import Crop32Icon from '@mui/icons-material/Crop32'; +import PanoramaFishEyeIcon from '@mui/icons-material/PanoramaFishEye'; +import GestureIcon from '@mui/icons-material/Gesture'; +import {ColorPicker} from '@alchemy/react-form'; +import {StateSetter} from '../../../../types.ts'; +import {useState} from 'react'; +import ToolbarPaper from '../Players/ToolbarPaper.tsx'; +import ModeIcon from '@mui/icons-material/Mode'; +import BrushIcon from '@mui/icons-material/Brush'; + +type Props = { + mode: AnnotationType | undefined; + setMode: StateSetter; + options: AnnotationOptions; + setOptions: StateSetter; +}; + +export default function AnnotateToolbar({ + mode, + setMode, + options, + setOptions, +}: Props) { + const [annotate, setAnnotate] = useState(false); + + return ( + <> + setAnnotate(p => !p)} + > + + + {annotate && ( + +
+ setMode(AnnotationType.Point)} + > + + +
+
+ setMode(AnnotationType.Rect)} + > + + +
+
+ setMode(AnnotationType.Circle)} + > + + +
+
+ setMode(AnnotationType.Draw)} + > + + +
+
+ setMode(AnnotationType.Highlight)} + > + + +
+
+ { + setOptions(p => ({...p, color: c})); + }} + /> +
+
+ + setOptions(p => ({ + ...p, + size: parseInt(e.target.value), + })) + } + /> +
+
+ )} + + ); +} diff --git a/databox/client/src/components/Media/Asset/Annotations/AnnotateWrapper.tsx b/databox/client/src/components/Media/Asset/Annotations/AnnotateWrapper.tsx new file mode 100644 index 000000000..88326641c --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/AnnotateWrapper.tsx @@ -0,0 +1,76 @@ +import {annotationZIndex} from './AssetAnnotationsOverlay.tsx'; +import React, {ReactNode, useRef, useState} from 'react'; +import {useAnnotationDraw} from './useAnnotationDraw.ts'; +import { + AnnotationOptions, + AnnotationType, + AssetAnnotation, + OnNewAnnotation, +} from './annotationTypes.ts'; +import AnnotateToolbar from './AnnotateToolbar.tsx'; + +type Props = { + onNewAnnotation?: OnNewAnnotation | undefined; + page?: number; + children: (props: { + canvas: ReactNode | null; + toolbar: ReactNode | null; + annotationActive: boolean; + }) => JSX.Element; +}; + +export default function AnnotateWrapper({ + onNewAnnotation, + page, + children, +}: Props) { + const canvasRef = useRef(null); + const [mode, setMode] = useState(undefined); + const [options, setOptions] = React.useState({ + color: '#000', + size: 2, + }); + + useAnnotationDraw({ + canvasRef, + onNewAnnotation: onNewAnnotation + ? (annotation: AssetAnnotation) => { + onNewAnnotation!({ + ...annotation, + page, + }); + } + : undefined, + onTerminate: () => setMode(undefined), + mode, + annotationOptions: options, + }); + + return ( + <> + {children({ + canvas: mode ? ( + + ) : null, + toolbar: onNewAnnotation ? ( + + ) : null, + annotationActive: !!mode, + })} + + ); +} diff --git a/databox/client/src/components/Media/Asset/Annotations/AssetAnnotationsOverlay.tsx b/databox/client/src/components/Media/Asset/Annotations/AssetAnnotationsOverlay.tsx index 833f3c7e6..9b3c79c38 100644 --- a/databox/client/src/components/Media/Asset/Annotations/AssetAnnotationsOverlay.tsx +++ b/databox/client/src/components/Media/Asset/Annotations/AssetAnnotationsOverlay.tsx @@ -1,42 +1,78 @@ -import {AnnotationType, AssetAnnotation} from '../../../../types.ts'; -import PointAnnotation from './PointAnnotation.tsx'; -import React, {FC} from 'react'; -import RectAnnotation from './RectAnnotation.tsx'; -import CircleAnnotation from './CircleAnnotation.tsx'; +import React, {forwardRef, memo, useCallback, useImperativeHandle} from 'react'; +import {AssetAnnotation} from './annotationTypes.ts'; +import {drawingHandlers} from './events.ts'; type Props = { annotations: AssetAnnotation[]; }; -export default function AssetAnnotationsOverlay({annotations}: Props) { - const types: { - [key in AnnotationType]?: FC; - } = { - [AnnotationType.Point]: PointAnnotation, - [AnnotationType.Rect]: RectAnnotation, - [AnnotationType.Circle]: CircleAnnotation, - }; - - return ( -
- {annotations.map(({type, ...props}, i) => { - if (!types[type]) { - return ''; - } - - return React.createElement(types[type]!, { - key: i, - ...props, +export const annotationZIndex = 100; + +export type AssetAnnotationHandle = { + render: () => void; +}; + +const AssetAnnotationsOverlay = memo( + forwardRef(function AssetAnnotationsOverlay( + {annotations}, + ref + ) { + const canvasRef = React.useRef(null); + + const render = useCallback(() => { + if (canvasRef.current) { + const canvas = canvasRef.current; + const parent = canvas.parentNode as HTMLDivElement; + const {offsetWidth: width, offsetHeight: height} = parent; + + const resolution = Math.max(devicePixelRatio, 2); + canvas.width = width * resolution; + canvas.height = height * resolution; + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + + const context = canvas!.getContext('2d')!; + context.scale(resolution, resolution); + + annotations.forEach(annotation => { + const handler = drawingHandlers[annotation.type]; + if (handler) { + context.globalAlpha = 1; + handler.drawAnnotation({ + context, + annotation, + toX: x => x * width, + toY: y => y * height, + }); + } }); - })} -
- ); -} + } + }, [canvasRef, annotations]); + + React.useEffect(() => { + render(); + }, [render]); + + useImperativeHandle(ref, () => { + return { + render, + }; + }, [render]); + + return ( + + ); + }) +); + +export default AssetAnnotationsOverlay; diff --git a/databox/client/src/components/Media/Asset/Annotations/CircleAnnotation.tsx b/databox/client/src/components/Media/Asset/Annotations/CircleAnnotation.tsx deleted file mode 100644 index 288b05b02..000000000 --- a/databox/client/src/components/Media/Asset/Annotations/CircleAnnotation.tsx +++ /dev/null @@ -1,34 +0,0 @@ -type Props = { - x: number; - y: number; - r: number; - b?: number; - c?: string; - f?: string; -}; - -export default function CircleAnnotation({ - x, - y, - r, - b = 3, - c = '#000', - f, -}: Props) { - return ( -
- ); -} diff --git a/databox/client/src/components/Media/Asset/Annotations/CircleAnnotationHandler.ts b/databox/client/src/components/Media/Asset/Annotations/CircleAnnotationHandler.ts new file mode 100644 index 000000000..6730dc8ba --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/CircleAnnotationHandler.ts @@ -0,0 +1,93 @@ +import {AnnotationOptions, AnnotationType} from './annotationTypes.ts'; +import {DrawingHandler} from './events.ts'; + +function drawCircle({ + x, + y, + context, + radius, + options, +}: { + x: number; + y: number; + context: CanvasRenderingContext2D; + radius: number; + options: AnnotationOptions; +}) { + const a = new Path2D(); + a.arc(x, y, radius, 0, 2 * Math.PI, false); + context.lineWidth = options.size; + context.strokeStyle = options.color; + context.stroke(a); +} + +function getRadius(deltaX: number, deltaY: number) { + return Math.abs( + 3 + + Math.max(Math.abs(deltaX), Math.abs(deltaY)) * + (deltaX < 0 || deltaY < 0 ? -1 : 1) + ); +} + +export const CircleAnnotationHandler: DrawingHandler = { + onDrawStart: ({x, y, context, options}) => { + drawCircle({ + x, + y, + context, + radius: 3, + options, + }); + }, + onDrawMove: ({ + clear, + startingPoint: {x, y}, + context, + deltaX, + deltaY, + options, + }) => { + clear(); + const radius = getRadius(deltaX, deltaY); + drawCircle({ + x, + y, + context, + radius, + options, + }); + }, + onDrawEnd: ({ + onNewAnnotation, + startingPoint: {x, y}, + deltaX, + deltaY, + relativeX, + relativeY, + options, + terminate, + }) => { + onNewAnnotation({ + type: AnnotationType.Circle, + x: relativeX(x), + y: relativeY(y), + r: relativeX(getRadius(deltaX, deltaY)), + c: options.color, + s: relativeX(options.size), + }); + terminate(); + }, + drawAnnotation: ({annotation: {x, y, r, c, s}, context, toX, toY}) => { + drawCircle({ + x: toX(x), + y: toY(y), + context, + radius: toX(r), + options: { + color: c, + size: toX(s), + }, + }); + }, + onTerminate: () => {}, +}; diff --git a/databox/client/src/components/Media/Asset/Annotations/DrawAnnotationHandler.ts b/databox/client/src/components/Media/Asset/Annotations/DrawAnnotationHandler.ts new file mode 100644 index 000000000..93550f1ec --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/DrawAnnotationHandler.ts @@ -0,0 +1,137 @@ +import { + AnnotationOptions, + AnnotationType, + DrawAnnotation, + Point, +} from './annotationTypes.ts'; +import {DrawingHandler} from './events.ts'; + +function init( + context: CanvasRenderingContext2D, + options: AnnotationOptions, + applyStyle: ApplyStyle | undefined +) { + context.lineWidth = options.size; + context.strokeStyle = options.color; + context.lineJoin = 'round'; + context.lineCap = 'round'; + context.beginPath(); + applyStyle?.(context); +} + +type ApplyStyle = (context: CanvasRenderingContext2D) => void; + +export function createDrawAnnotationHandler( + annotationType: AnnotationType, + onPoint: (props: { + context: CanvasRenderingContext2D; + point: Point; + index: number; + options: AnnotationOptions; + }) => void, + applyStyle?: ApplyStyle +): DrawingHandler { + return { + onDrawStart: ({context, x, y, data, options}) => { + init(context, options, applyStyle); + onPoint({ + context, + point: { + x, + y, + }, + index: 0, + options, + }); + data.points = [{x, y}]; + data.paths ??= []; + data.paths.push(data.points); + }, + onDrawMove: ({context, x, y, data, options}) => { + if (x <= 0) { + x = 0; + } + if (y <= 0) { + y = 0; + } + applyStyle?.(context); + onPoint({ + context, + point: { + x, + y, + }, + index: 1, + options, + }); + data.points.push({x, y}); + }, + onDrawEnd: ({context, terminate, data}) => { + context.closePath(); + + if (data.points.length === 1) { + data.paths.pop(); + terminate(); + } + }, + onTerminate: ({ + data, + context, + onNewAnnotation, + relativeX, + relativeY, + options, + }) => { + context.closePath(); + if (data.paths.length > 0) { + onNewAnnotation({ + type: annotationType, + paths: data.paths.map((points: Point[]) => + points.map((p: Point) => { + return { + x: relativeX(p.x), + y: relativeY(p.y), + }; + }) + ), + c: options.color, + s: relativeX(options.size), + }); + } + }, + drawAnnotation: ({annotation: {paths, c, s}, context, toX, toY}) => { + const options = { + color: c, + size: toX(s), + }; + init(context, options, applyStyle); + + (paths as DrawAnnotation['paths']).forEach(path => + path.forEach((point: Point, i) => { + onPoint({ + context, + point: { + x: toX(point.x), + y: toY(point.y), + }, + index: i, + options: options, + }); + }) + ); + context.closePath(); + }, + }; +} + +export const DrawAnnotationHandler = createDrawAnnotationHandler( + AnnotationType.Draw, + ({context, point, index}) => { + if (index === 0) { + context.moveTo(point.x, point.y); + } else { + context.lineTo(point.x, point.y); + } + context.stroke(); + } +); diff --git a/databox/client/src/components/Media/Asset/Annotations/HighlightAnnotationHandler.ts b/databox/client/src/components/Media/Asset/Annotations/HighlightAnnotationHandler.ts new file mode 100644 index 000000000..aca4ee243 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/HighlightAnnotationHandler.ts @@ -0,0 +1,14 @@ +import {AnnotationType} from './annotationTypes.ts'; +import {createDrawAnnotationHandler} from './DrawAnnotationHandler.ts'; + +export const HighlightAnnotationHandler = createDrawAnnotationHandler( + AnnotationType.Highlight, + ({context, point, options: {size}}) => { + context.fillStyle = '#ff0'; + context.fillRect(point.x - size / 2, point.y - size / 2, size, size); + }, + context => { + context.globalCompositeOperation = 'multiply'; + context.globalAlpha = 0.2; + } +); diff --git a/databox/client/src/components/Media/Asset/Annotations/PointAnnotation.tsx b/databox/client/src/components/Media/Asset/Annotations/PointAnnotation.tsx deleted file mode 100644 index 12cdc9ca1..000000000 --- a/databox/client/src/components/Media/Asset/Annotations/PointAnnotation.tsx +++ /dev/null @@ -1,24 +0,0 @@ -type Props = { - x: number; - y: number; - s?: number; - c?: string; -}; - -export default function PointAnnotation({x, y, s = 30, c = '#000'}: Props) { - return ( -
- ); -} diff --git a/databox/client/src/components/Media/Asset/Annotations/PointAnnotationHandler.ts b/databox/client/src/components/Media/Asset/Annotations/PointAnnotationHandler.ts new file mode 100644 index 000000000..416304339 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/PointAnnotationHandler.ts @@ -0,0 +1,69 @@ +import {AnnotationOptions, AnnotationType} from './annotationTypes.ts'; +import {DrawingHandler} from './events.ts'; + +function drawPoint({ + x, + y, + context, + options, +}: { + x: number; + y: number; + context: CanvasRenderingContext2D; + options: AnnotationOptions; +}) { + const a = new Path2D(); + a.arc(x, y, options.size, 0, 2 * Math.PI, false); + context.fillStyle = options.color; + context.fill(a); +} + +export const PointAnnotationHandler: DrawingHandler = { + onDrawStart: ({x, y, context, options}) => { + drawPoint({ + x, + y, + context, + options, + }); + }, + onDrawMove: ({clear, context, x, y, options}) => { + clear(); + drawPoint({ + x, + y, + context, + options, + }); + }, + onDrawEnd: ({ + onNewAnnotation, + x, + y, + relativeX, + relativeY, + options, + terminate, + }) => { + onNewAnnotation({ + type: AnnotationType.Point, + x: relativeX(x), + y: relativeY(y), + c: options.color, + s: relativeX(options.size), + }); + terminate(); + }, + drawAnnotation: ({annotation: {x, y, c, s}, context, toX, toY}) => { + drawPoint({ + x: toX(x), + y: toY(y), + context, + options: { + color: c, + size: toX(s), + }, + }); + }, + onTerminate: () => {}, +}; diff --git a/databox/client/src/components/Media/Asset/Annotations/RectAnnotation.tsx b/databox/client/src/components/Media/Asset/Annotations/RectAnnotation.tsx deleted file mode 100644 index 28e745aa3..000000000 --- a/databox/client/src/components/Media/Asset/Annotations/RectAnnotation.tsx +++ /dev/null @@ -1,34 +0,0 @@ -type Props = { - x1: number; - y1: number; - x2: number; - y2: number; - b?: number; - c?: string; - f?: string; -}; - -export default function RectAnnotation({ - x1, - y1, - x2, - y2, - b = 3, - c = '#000', - f, -}: Props) { - return ( -
- ); -} diff --git a/databox/client/src/components/Media/Asset/Annotations/RectAnnotationHandler.ts b/databox/client/src/components/Media/Asset/Annotations/RectAnnotationHandler.ts new file mode 100644 index 000000000..2eabd453d --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/RectAnnotationHandler.ts @@ -0,0 +1,112 @@ +import { + AnnotationOptions, + AnnotationType, + RectangleAnnotation, +} from './annotationTypes.ts'; +import {DrawingHandler} from './events.ts'; + +function drawRectangle({ + x, + y, + w, + h, + context, + options, +}: { + x: number; + y: number; + w: number; + h: number; + context: CanvasRenderingContext2D; + options: AnnotationOptions; +}) { + const a = new Path2D(); + a.rect(x, y, w, h); + context.strokeStyle = options.color; + context.lineWidth = options.size; + context.stroke(a); +} + +export const RectAnnotationHandler: DrawingHandler = { + onDrawStart: ({x, y, context, options}) => { + drawRectangle({ + x, + y, + w: 0, + h: 0, + context, + options, + }); + }, + onDrawMove: ({ + clear, + context, + startingPoint: {x, y}, + deltaY, + deltaX, + options, + }) => { + clear(); + drawRectangle({ + x, + y, + w: deltaX, + h: deltaY, + context, + options, + }); + }, + onDrawEnd: ({ + onNewAnnotation, + startingPoint: {x, y}, + deltaY, + deltaX, + relativeX, + relativeY, + options, + terminate, + }) => { + const x1 = relativeX(x); + const y1 = relativeY(y); + + const x2 = relativeX(x + deltaX); + const y2 = relativeY(y + deltaY); + + const props: Partial = { + type: AnnotationType.Rect, + c: options.color, + s: relativeX(options.size), + }; + + if (x1 > x2) { + props.x1 = x2; + props.x2 = x1; + } else { + props.x1 = x1; + props.x2 = x2; + } + + if (y1 > y2) { + props.y1 = y2; + props.y2 = y1; + } else { + props.y1 = y1; + props.y2 = y2; + } + + onNewAnnotation(props as RectangleAnnotation); + terminate(); + }, + drawAnnotation: ({annotation, context, toX, toY}) => { + const {x1, y1, x2, y2, c, s} = annotation; + drawRectangle({ + x: toX(x1), + y: toY(y1), + w: toX(x2 - x1), + h: toY(y2 - y1), + context, + options: {color: c, size: toX(s)}, + }); + }, + onTerminate: () => {}, +}; diff --git a/databox/client/src/components/Media/Asset/Annotations/annotationTypes.ts b/databox/client/src/components/Media/Asset/Annotations/annotationTypes.ts new file mode 100644 index 000000000..5a28d1072 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/annotationTypes.ts @@ -0,0 +1,79 @@ +import {MutableRefObject} from 'react'; + +export type Point = { + x: number; + y: number; +}; + +export enum AnnotationType { + Point = 'point', + Draw = 'draw', + Highlight = 'highlight', + Circle = 'circle', + Rect = 'rect', + Cue = 'cue', + TimeRange = 'time_range', +} + +export interface AssetAnnotation { + type: AnnotationType; + name?: string; + [prop: string]: any; +} + +export interface PointAnnotation extends AssetAnnotation { + type: AnnotationType.Point; + x: number; + y: number; + c?: string; // Color + s?: number; // Size + page?: number; +} + +export interface CircleAnnotation extends AssetAnnotation { + type: AnnotationType.Circle; + x: number; + y: number; + radius: number; + c?: string; // Border color + f?: string; // Fill color + s?: number; // Stroke size + page?: number; +} + +export type AnnotationOptions = { + color: string; + size: number; +}; + +export interface RectangleAnnotation extends AssetAnnotation { + type: AnnotationType.Rect; + x1: number; + y1: number; + x2: number; + y2: number; + c?: string; // Border color + f?: string; // Fill color + s?: number; // Stroke size +} + +export interface DrawAnnotation extends AssetAnnotation { + type: AnnotationType.Draw; + paths: Point[][]; + c?: string; // Color + s?: number; // Line width +} + +export interface CueAnnotation extends AssetAnnotation { + type: AnnotationType.Cue; + t: number; // Time in seconds +} + +export interface TimeRangeAnnotation extends AssetAnnotation { + type: AnnotationType.TimeRange; + s: number; // Start time in seconds + e: number; // End time in seconds +} + +export type OnNewAnnotation = (annotation: AssetAnnotation) => void; +export type OnNewAnnotationRef = MutableRefObject; diff --git a/databox/client/src/components/Media/Asset/Annotations/events.ts b/databox/client/src/components/Media/Asset/Annotations/events.ts new file mode 100644 index 000000000..57427399d --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/events.ts @@ -0,0 +1,77 @@ +import { + AnnotationOptions, + AnnotationType, + AssetAnnotation, + OnNewAnnotation, + Point, +} from './annotationTypes.ts'; +import {DrawAnnotationHandler} from './DrawAnnotationHandler.ts'; +import {RectAnnotationHandler} from './RectAnnotationHandler.ts'; +import {PointAnnotationHandler} from './PointAnnotationHandler.ts'; +import {CircleAnnotationHandler} from './CircleAnnotationHandler.ts'; +import {HighlightAnnotationHandler} from './HighlightAnnotationHandler.ts'; + +export type StartingPoint = Point; + +type Clear = () => void; + +type BaseEvent = { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + startingPoint: StartingPoint; + data: any; + options: AnnotationOptions; +}; + +type OnStartDrawingEvent = {} & Point & BaseEvent; + +type OnDrawMoveEvent = { + deltaX: number; + deltaY: number; + clear: Clear; +} & Point & + BaseEvent; + +type OnEndDrawingEvent = { + deltaX: number; + deltaY: number; + onNewAnnotation: OnNewAnnotation; + terminate: () => void; + relativeX: (x: number) => number; + relativeY: (y: number) => number; +} & Point & + BaseEvent; + +type OnTerminateEvent = { + onNewAnnotation: OnNewAnnotation; + relativeX: (x: number) => number; + relativeY: (y: number) => number; +} & BaseEvent; + +type OnStartDrawing = (event: OnStartDrawingEvent) => void; +type OnDrawMove = (event: OnDrawMoveEvent) => void; +type OnEndDrawing = (event: OnEndDrawingEvent) => void; +type OnTerminate = (event: OnTerminateEvent) => void; + +type DrawAnnotationProps = { + annotation: AssetAnnotation; + context: CanvasRenderingContext2D; + toX: (relativeX: number) => number; + toY: (relativeY: number) => number; +}; + +export type DrawingHandler = { + onDrawStart: OnStartDrawing; + onDrawMove: OnDrawMove; + onDrawEnd: OnEndDrawing; + onTerminate: OnTerminate; + drawAnnotation: (props: DrawAnnotationProps) => void; +}; + +export const drawingHandlers: Record = { + [AnnotationType.Circle]: CircleAnnotationHandler, + [AnnotationType.Point]: PointAnnotationHandler, + [AnnotationType.Rect]: RectAnnotationHandler, + [AnnotationType.Draw]: DrawAnnotationHandler, + [AnnotationType.Highlight]: HighlightAnnotationHandler, +} as Record; diff --git a/databox/client/src/components/Media/Asset/Annotations/useAnnotationDraw.ts b/databox/client/src/components/Media/Asset/Annotations/useAnnotationDraw.ts new file mode 100644 index 000000000..d80d22ca0 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Annotations/useAnnotationDraw.ts @@ -0,0 +1,210 @@ +import React, {useRef} from 'react'; +import {drawingHandlers, StartingPoint} from './events.ts'; +import { + AnnotationOptions, + AnnotationType, + OnNewAnnotation, +} from './annotationTypes.ts'; + +type Props = { + canvasRef: React.MutableRefObject; + onNewAnnotation: OnNewAnnotation | undefined; + mode: AnnotationType | undefined; + annotationOptions: AnnotationOptions; + onTerminate: () => void; +}; + +export function useAnnotationDraw({ + canvasRef, + onNewAnnotation, + onTerminate: onTerminateProp, + mode, + annotationOptions, +}: Props) { + const startingPoint = useRef(); + const dataRef = useRef(); + + React.useEffect(() => { + if ( + onNewAnnotation && + mode && + canvasRef.current && + mode in drawingHandlers + ) { + const canvas = canvasRef.current; + const parent = canvas.parentNode as HTMLDivElement; + const {offsetWidth: width, offsetHeight: height} = parent; + const relativeX = (x: number) => x / width; + const relativeY = (y: number) => y / height; + + const {onDrawStart, onDrawMove, onDrawEnd, onTerminate} = + drawingHandlers[mode]; + + const resolution = Math.max(devicePixelRatio, 2); + canvas.width = width * resolution; + canvas.height = height * resolution; + canvas.style.width = width + 'px'; + canvas.style.height = height + 'px'; + + const context = canvas!.getContext('2d')!; + context.scale(resolution, resolution); + + const reset = () => { + canvas.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + stopEvents.forEach(event => { + window.removeEventListener(event, onStopHandler); + }); + cancelEvents.forEach(event => { + window.removeEventListener(event, onCancel); + }); + startingPoint.current = undefined; + dataRef.current = undefined; + }; + + const onMouseMove = (event: MouseEvent) => { + const x = event.offsetX; + const y = event.offsetY; + + const st = startingPoint.current!; + + onDrawMove({ + options: annotationOptions, + data: dataRef.current!, + context, + canvas, + startingPoint: st, + x, + y, + deltaX: x - st.x, + deltaY: y - st.y, + clear: () => + context.clearRect(0, 0, canvas.width, canvas.height), + }); + }; + + const terminateHandler = () => { + onTerminate({ + options: annotationOptions, + data: dataRef.current!, + context, + onNewAnnotation, + canvas, + startingPoint: startingPoint.current!, + relativeX, + relativeY, + }); + onTerminateProp(); + reset(); + }; + + const cancelHandler = () => { + onTerminate({ + options: annotationOptions, + data: dataRef.current!, + context, + onNewAnnotation: () => {}, + canvas, + startingPoint: startingPoint.current!, + relativeX, + relativeY, + }); + onTerminateProp(); + reset(); + }; + + const onMouseUp = (event: MouseEvent) => { + canvas.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + const st = startingPoint.current!; + const x = event.offsetX; + const y = event.offsetY; + + onDrawEnd({ + options: annotationOptions, + data: dataRef.current!, + context, + onNewAnnotation, + terminate: terminateHandler, + canvas, + startingPoint: st, + x, + y, + deltaX: x - st.x, + deltaY: y - st.y, + relativeX, + relativeY, + }); + }; + + const onCancel = (event: any) => { + if ( + event.type === 'keydown' && + (event as KeyboardEvent).key !== 'Escape' + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + cancelHandler(); + }; + + const onStopHandler = (event: any) => { + if ( + event.type === 'keydown' && + (event as KeyboardEvent).key === 'Escape' + ) { + event.stopPropagation(); + return; + } + event.preventDefault(); + + terminateHandler(); + }; + + const stopEvents = ['contextmenu', 'keydown']; + const cancelEvents = ['keydown']; + stopEvents.forEach(event => { + window.addEventListener(event, onStopHandler); + }); + cancelEvents.forEach(event => { + window.addEventListener(event, onCancel); + }); + + const onMouseDown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const x = event.offsetX; + const y = event.offsetY; + + startingPoint.current = { + x, + y, + }; + + dataRef.current ??= {}; + + onDrawStart({ + options: annotationOptions, + data: dataRef.current!, + context, + canvas, + startingPoint: startingPoint.current!, + x, + y, + }); + + canvas.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + }; + + canvas.addEventListener('mousedown', onMouseDown); + + return () => { + canvas.removeEventListener('mousedown', onMouseDown); + reset(); + }; + } + }, [canvasRef, mode, annotationOptions]); +} diff --git a/databox/client/src/components/Media/Asset/AssetAttributes.tsx b/databox/client/src/components/Media/Asset/AssetAttributes.tsx index a878fff12..dcde9be31 100644 --- a/databox/client/src/components/Media/Asset/AssetAttributes.tsx +++ b/databox/client/src/components/Media/Asset/AssetAttributes.tsx @@ -8,44 +8,43 @@ import { import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import Attributes, { attributesSx, - OnAnnotations, + OnActiveAnnotations, } from './Attribute/Attributes.tsx'; -import React from 'react'; +import React, {memo} from 'react'; import {Asset} from '../../../types.ts'; import {useTranslation} from 'react-i18next'; type Props = { asset: Asset; - onAnnotations: OnAnnotations | undefined; + onActiveAnnotations: OnActiveAnnotations | undefined; }; -export default function AssetAttributes({asset, onAnnotations}: Props) { +function AssetAttributes({asset, onActiveAnnotations}: Props) { const [expanded, setExpanded] = React.useState(true); const {t} = useTranslation(); return ( - - setExpanded(p => !p)} + setExpanded(p => !p)}> + } + aria-controls="attr-content" + id="attr-header" > - } - aria-controls="attr-content" - id="attr-header" - > - - {t('asset.view.attributes', `Asset Attributes`)} - - - + + {t('asset.view.attributes', `Asset Attributes`)} + + + + - - - + + + ); } + +export default memo(AssetAttributes); diff --git a/databox/client/src/components/Media/Asset/AssetDiscussion.tsx b/databox/client/src/components/Media/Asset/AssetDiscussion.tsx new file mode 100644 index 000000000..bad6800d9 --- /dev/null +++ b/databox/client/src/components/Media/Asset/AssetDiscussion.tsx @@ -0,0 +1,54 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Typography, +} from '@mui/material'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import {OnActiveAnnotations} from './Attribute/Attributes.tsx'; +import React, {memo} from 'react'; +import {Asset} from '../../../types.ts'; +import {useTranslation} from 'react-i18next'; +import Thread from '../../Discussion/Thread.tsx'; +import {OnNewAnnotationRef} from './Annotations/annotationTypes.ts'; + +type Props = { + asset: Asset; + onActiveAnnotations?: OnActiveAnnotations | undefined; + onNewAnnotationRef?: OnNewAnnotationRef; +}; + +function AssetDiscussion({ + asset, + onActiveAnnotations, + onNewAnnotationRef, +}: Props) { + const [expanded, setExpanded] = React.useState(true); + const {t} = useTranslation(); + + return ( + setExpanded(p => !p)}> + } + aria-controls="attr-content" + id="attr-header" + > + + {t('asset.view.discussion', `Discussion`)} + + + + + + + ); +} + +export default memo(AssetDiscussion, (a, b) => { + return a.asset.id === b.asset.id; +}); diff --git a/databox/client/src/components/Media/Asset/AssetView.tsx b/databox/client/src/components/Media/Asset/AssetView.tsx deleted file mode 100644 index 3ce313b64..000000000 --- a/databox/client/src/components/Media/Asset/AssetView.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import React, {FC, useCallback, useMemo, useState} from 'react'; -import {Asset, AssetAnnotation, AssetRendition} from '../../../types'; -import {AppDialog} from '@alchemy/phrasea-ui'; -import FilePlayer from './FilePlayer'; -import {useWindowSize} from '@alchemy/react-hooks/src/useWindowSize'; -import {StackedModalProps, useParams} from '@alchemy/navigation'; -import {Dimensions} from './Players'; -import {Box, Select} from '@mui/material'; -import FileIntegrations from './FileIntegrations'; -import {getAsset} from '../../../api/asset'; -import FullPageLoader from '../../Ui/FullPageLoader'; -import RouteDialog from '../../Dialog/RouteDialog'; -import {getAssetRenditions} from '../../../api/rendition'; -import MenuItem from '@mui/material/MenuItem'; -import {useNavigateToModal} from '../../Routing/ModalLink'; -import {modalRoutes} from '../../../routes'; -import {scrollbarWidth} from '../../../constants.ts'; -import AssetAttributes from './AssetAttributes.tsx'; -import {OnAnnotations} from './Attribute/Attributes.tsx'; -import AssetAnnotationsOverlay from './Annotations/AssetAnnotationsOverlay.tsx'; -import AssetViewActions from './Actions/AssetViewActions.tsx'; -import {Trans} from 'react-i18next'; -import {getMediaBackgroundColor} from '../../../themes/base.ts'; -import {useModalFetch} from '../../../hooks/useModalFetch.ts'; -import {useChannelRegistration} from "../../../lib/pusher.ts"; -import {queryClient} from "../../../lib/query.ts"; - -export type IntegrationOverlayCommonProps = { - dimensions: Dimensions; -}; - -type IntegrationOverlay

= { - component: FC

; - props: P; - replace: boolean; -}; - -export type SetIntegrationOverlayFunction

= ( - component: FC

| null, - props?: P, - replace?: boolean -) => void; - -type Props = {} & StackedModalProps; - -export default function AssetView({modalIndex, open}: Props) { - const menuWidth = 300; - const headerHeight = 60; - const {id: assetId, renditionId} = useParams(); - const navigateToModal = useNavigateToModal(); - const [annotations, setAnnotations] = React.useState< - AssetAnnotation[] | undefined - >(); - - const queryKey = ['assets', assetId]; - - useChannelRegistration( - `asset-${assetId}`, - `asset_ingested`, - () => { - queryClient.invalidateQueries({queryKey}); - } - ); - - const {data, isSuccess} = useModalFetch({ - queryKey, - staleTime: 2000, - queryFn: () => - Promise.all([ - getAsset(assetId!), - getAssetRenditions(assetId!).then(r => r.result), - ]), - }); - - const onAnnotations = React.useCallback(annotations => { - setAnnotations(annotations); - }, []); - - const winSize = useWindowSize(); - const [integrationOverlay, setIntegrationOverlay] = - useState(null); - - const setProxy: SetIntegrationOverlayFunction = useCallback( - (component, props, replace = false) => { - if (!component) { - setIntegrationOverlay(null); - } else { - setIntegrationOverlay({ - component, - props, - replace, - }); - } - }, - [setIntegrationOverlay] - ); - - const dimensions = useMemo(() => { - return { - width: winSize.innerWidth - menuWidth - scrollbarWidth, - height: winSize.innerHeight - headerHeight - 2, - }; - }, [winSize]); - - if (!isSuccess) { - if (!open) { - return null; - } - return ; - } - - const [asset, renditions] = data as [Asset, AssetRendition[]]; - const rendition = renditions.find(r => r.id === renditionId); - - const handleRenditionChange = (renditionId: string) => { - navigateToModal(modalRoutes.assets.routes.view, { - id: assetId, - renditionId, - }); - }; - - return ( - - {({open, onClose}) => ( - - {{name}}' - } - /> - - sx={{ml: 2}} - label={''} - size={'small'} - value={rendition?.id} - onChange={e => - handleRenditionChange(e.target.value) - } - > - {renditions.map((r: AssetRendition) => ( - - {r.name} - - ))} - - {!integrationOverlay ? ( - - ) : ( - '' - )} - - } - onClose={onClose} - > - - ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - overflowY: 'auto', - height: dimensions.height, - width: dimensions.width + scrollbarWidth, - maxWidth: dimensions.width + scrollbarWidth, - backgroundColor: getMediaBackgroundColor(theme), - })} - > -

- {annotations && !integrationOverlay ? ( - - ) : ( - '' - )} - {rendition?.file && - (!integrationOverlay || - !integrationOverlay.replace) && ( - - )} - {integrationOverlay && - React.createElement( - integrationOverlay.component, - { - dimensions, - ...(integrationOverlay.props || {}), - } - )} -
- - ({ - width: menuWidth, - maxWidth: menuWidth, - borderLeft: `1px solid ${theme.palette.divider}`, - overflowY: 'auto', - height: dimensions.height, - })} - > - - {rendition?.file ? ( - - ) : ( - '' - )} - - - - )} - - ); -} diff --git a/databox/client/src/components/Media/Asset/AssetViewNavigation.tsx b/databox/client/src/components/Media/Asset/AssetViewNavigation.tsx new file mode 100644 index 000000000..86b360f9a --- /dev/null +++ b/databox/client/src/components/Media/Asset/AssetViewNavigation.tsx @@ -0,0 +1,55 @@ +import {Box, IconButton} from '@mui/material'; +import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import {AssetContextState} from './assetTypes.ts'; +import {useNavigateToModal} from '../../Routing/ModalLink.tsx'; +import {modalRoutes} from '../../../routes.ts'; + +type Props = { + currentId: string; + state: AssetContextState | undefined; +}; + +export default function AssetViewNavigation({currentId, state}: Props) { + const navigateToModal = useNavigateToModal(); + const {assetsContext} = state ?? {}; + if (!assetsContext) { + return null; + } + + const currentIndex = assetsContext.findIndex(t => t[0] === currentId); + + const goTo = (index: number) => { + const [id, renditionId] = assetsContext[index]; + + navigateToModal( + modalRoutes.assets.routes.view, + { + id, + renditionId, + }, + {state} + ); + }; + + return ( + + goTo(currentIndex - 1)} + > + + + goTo(currentIndex + 1)} + > + + + + ); +} diff --git a/databox/client/src/components/Media/Asset/Attribute/AttributeRowUI.tsx b/databox/client/src/components/Media/Asset/Attribute/AttributeRowUI.tsx index 918d46818..98393bfa8 100644 --- a/databox/client/src/components/Media/Asset/Attribute/AttributeRowUI.tsx +++ b/databox/client/src/components/Media/Asset/Attribute/AttributeRowUI.tsx @@ -5,9 +5,10 @@ import {getAttributeType} from './types'; import PushPinIcon from '@mui/icons-material/PushPin'; import CopyAttribute, {copyToClipBoardContainerClass} from './CopyAttribute'; import React from 'react'; -import {attributesClasses, OnAnnotations} from './Attributes'; +import {attributesClasses, OnActiveAnnotations} from './Attributes'; import {isRtlLocale} from '../../../../lib/lang'; import {Attribute, AttributeDefinition} from '../../../../types.ts'; +import GestureIcon from '@mui/icons-material/Gesture'; type Props = { definition: AttributeDefinition; @@ -16,7 +17,7 @@ type Props = { togglePin: undefined | ((definitionId: string) => void); pinned: boolean; formatContext: TAttributeFormatContext; - onAnnotations?: OnAnnotations | undefined; + onActiveAnnotations?: OnActiveAnnotations | undefined; }; export default function AttributeRowUI({ @@ -26,7 +27,7 @@ export default function AttributeRowUI({ pinned, displayControls, formatContext, - onAnnotations, + onActiveAnnotations, }: Props) { const {id, name, fieldType, multiple} = definition; const formatter = getAttributeType(fieldType); @@ -132,17 +133,26 @@ export default function AttributeRowUI({ className={ copyToClipBoardContainerClass } - onMouseEnter={ - onAnnotations && - a.assetAnnotations - ? () => - onAnnotations( - a.assetAnnotations! - ) - : undefined - } > {formatter.formatValue(formatProps)} + {displayControls && + onActiveAnnotations && + a.assetAnnotations ? ( + { + e.stopPropagation(); + onActiveAnnotations!( + a.assetAnnotations! + ); + }} + > + + + ) : null} {displayControls ? ( void; +export type OnActiveAnnotations = (annotations: AssetAnnotation[]) => void; type Props = { asset: Asset; displayControls: boolean; pinnedOnly?: boolean; - onAnnotations?: OnAnnotations | undefined; + onActiveAnnotations?: OnActiveAnnotations | undefined; }; function Attributes({ asset, displayControls, pinnedOnly, - onAnnotations, + onActiveAnnotations, }: Props) { const {preferences, updatePreference} = useContext(UserPreferencesContext); const formatContext = useContext(AttributeFormatContext); @@ -90,7 +91,7 @@ function Attributes({ displayControls={displayControls} pinned={pinnedAttributes.includes(g.definition.id)} togglePin={asset.workspace ? togglePin : undefined} - onAnnotations={onAnnotations} + onActiveAnnotations={onActiveAnnotations} /> ); })} diff --git a/databox/client/src/components/Media/Asset/Attribute/BatchActions.ts b/databox/client/src/components/Media/Asset/Attribute/BatchActions.ts index 49a3499a6..1f825f758 100644 --- a/databox/client/src/components/Media/Asset/Attribute/BatchActions.ts +++ b/databox/client/src/components/Media/Asset/Attribute/BatchActions.ts @@ -121,7 +121,10 @@ export function getBatchActions( if ( !attributes[defId] || !attributes[defId][locale] || - isUndefined((attributes[defId][locale] as AttrValue).value)) { + isUndefined( + (attributes[defId][locale] as AttrValue).value + ) + ) { actions.push({ action: AttributeBatchActionEnum.Delete, definitionId: defId, @@ -144,7 +147,6 @@ export function getBatchActions( }); } - function isUndefined(value: any): boolean { return undefined === value || '' === value; } diff --git a/databox/client/src/components/Media/Asset/FileIntegrations.tsx b/databox/client/src/components/Media/Asset/FileIntegrations.tsx index 4b5577b5e..93bb41c3d 100644 --- a/databox/client/src/components/Media/Asset/FileIntegrations.tsx +++ b/databox/client/src/components/Media/Asset/FileIntegrations.tsx @@ -5,7 +5,6 @@ import { AccordionDetails, AccordionSummary, CircularProgress, - List, Typography, } from '@mui/material'; import { @@ -14,7 +13,7 @@ import { ObjectType, } from '../../../api/integrations'; import RemoveBGAssetEditorActions from '../../Integration/RemoveBG/RemoveBGAssetEditorActions'; -import {SetIntegrationOverlayFunction} from './AssetView'; +import {SetIntegrationOverlayFunction} from './View/AssetView.tsx'; import AwsRekognitionAssetEditorActions from '../../Integration/AwsRekognition/AwsRekognitionAssetEditorActions'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import TUIPhotoEditor from '../../Integration/TuiPhotoEditor/TUIPhotoEditor'; @@ -60,7 +59,7 @@ function IntegrationProxy({ // eslint-disable-next-line no-prototype-builtins if ( - integrations.hasOwnProperty(i) && + Object.hasOwnProperty.call(integrations, i) && integrations[i].supports(props.file) ) { return ( @@ -124,31 +123,24 @@ export default function FileIntegrations({ return ( <> {!integrations && } - {integrations && ( - - {integrations.map(i => ( - { - enableIncs.current[i.id] = enableIncs.current[ - i.id - ] - ? enableIncs.current[i.id] + 1 - : 1; - setExpanded(p => - p === i.id ? undefined : i.id - ); - }} - integration={i} - asset={asset} - file={file} - enableInc={enableIncs.current[i.id]} - setIntegrationOverlay={setIntegrationOverlay} - /> - ))} - - )} + {integrations && + integrations.map(i => ( + { + enableIncs.current[i.id] = enableIncs.current[i.id] + ? enableIncs.current[i.id] + 1 + : 1; + setExpanded(p => (p === i.id ? undefined : i.id)); + }} + integration={i} + asset={asset} + file={file} + enableInc={enableIncs.current[i.id]} + setIntegrationOverlay={setIntegrationOverlay} + /> + ))} ); } diff --git a/databox/client/src/components/Media/Asset/FilePlayer.tsx b/databox/client/src/components/Media/Asset/FilePlayer.tsx index 398b8c317..3abf6e27a 100644 --- a/databox/client/src/components/Media/Asset/FilePlayer.tsx +++ b/databox/client/src/components/Media/Asset/FilePlayer.tsx @@ -2,77 +2,40 @@ import {File} from '../../../types'; import {FileTypeEnum, getFileTypeFromMIMEType} from '../../../lib/file'; import AssetFileIcon from './AssetFileIcon'; import VideoPlayer from './Players/VideoPlayer'; -import {Dimensions, FileWithUrl} from './Players'; +import {FileWithUrl, PlayerProps} from './Players'; import PDFPlayer from './Players/PDFPlayer'; +import ImagePlayer from './Players/ImagePlayer.tsx'; type Props = { file: File; - controls?: boolean | undefined; - title: string | undefined; - onLoad?: () => void; - noInteraction?: boolean; autoPlayable?: boolean; - dimensions?: Dimensions; -}; +} & Omit; -export default function FilePlayer({ - file, - title, - onLoad, - controls, - noInteraction, - autoPlayable, - dimensions, -}: Props) { +export default function FilePlayer({file, autoPlayable, ...playProps}: Props) { const mainType = getFileTypeFromMIMEType(file.type); - if (!file.url) { - return ; - } - - switch (mainType) { - case FileTypeEnum.Image: { - const isSvg = file.type === 'image/svg+xml'; + if (file.url) { + const props: PlayerProps = { + ...playProps, + file: file as FileWithUrl, + }; - return ( - {title} - ); - } - case FileTypeEnum.Audio: - case FileTypeEnum.Video: - return ( - - ); - case FileTypeEnum.Document: - if (file.type === 'application/pdf') { + switch (mainType) { + case FileTypeEnum.Image: + return ; + case FileTypeEnum.Audio: + case FileTypeEnum.Video: return ( - ); - } + case FileTypeEnum.Document: + if (file.type === 'application/pdf') { + return ; + } + } } return ; diff --git a/databox/client/src/components/Media/Asset/Players/FileToolbar.tsx b/databox/client/src/components/Media/Asset/Players/FileToolbar.tsx new file mode 100644 index 000000000..d07a815d9 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Players/FileToolbar.tsx @@ -0,0 +1,167 @@ +import AssetAnnotationsOverlay, { + AssetAnnotationHandle, +} from '../Annotations/AssetAnnotationsOverlay.tsx'; +import AnnotateWrapper from '../Annotations/AnnotateWrapper.tsx'; +import {MutableRefObject, useCallback, useRef, useState} from 'react'; +import { + AssetAnnotation, + OnNewAnnotation, +} from '../Annotations/annotationTypes.ts'; +import ZoomControls from './ZoomControls.tsx'; +import {TransformComponent, TransformWrapper} from 'react-zoom-pan-pinch'; +import {Box, IconButton} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import MenuOpenIcon from '@mui/icons-material/MenuOpen'; +import {filePlayerRelativeWrapperClassName} from './index.ts'; +import ToolbarPaper from './ToolbarPaper.tsx'; + +type Props = { + annotationEnabled?: boolean; + zoomEnabled?: boolean; + onNewAnnotation?: OnNewAnnotation | undefined; + annotations?: AssetAnnotation[] | undefined; + page?: number; + controls?: boolean | undefined; + preToolbarActions?: JSX.Element | undefined; + forceHand?: boolean; + children: + | ((props: { + annotationsOverlayRef: MutableRefObject; + }) => JSX.Element) + | JSX.Element; +}; + +export default function FileToolbar({ + annotations, + annotationEnabled, + zoomEnabled, + onNewAnnotation, + children, + page, + controls, + preToolbarActions, + forceHand, +}: Props) { + const annotationsOverlayRef = useRef(null); + const [closed, setClosed] = useState(false); + const [hand, setHand] = useState(forceHand ?? false); + const contentRef = useRef(null); + + const fitContentToWrapper = useCallback( + (centerView: (scale: number) => void) => { + if (contentRef.current) { + const wrapperEl = contentRef.current.closest( + `.${filePlayerRelativeWrapperClassName}` + ); + if (wrapperEl) { + const widthScale = + wrapperEl.clientWidth / contentRef.current.clientWidth; + const heightScale = + wrapperEl.clientHeight / + contentRef.current.clientHeight; + const scale = + widthScale < heightScale ? widthScale : heightScale; + + centerView(scale); + } + } + }, + [contentRef] + ); + + return ( + <> + + {({canvas, annotationActive, toolbar}) => ( + + {controls ? ( + ({ + bottom: theme.spacing(2), + left: !closed ? '50%' : theme.spacing(2), + transform: !closed + ? 'translateX(-50%)' + : undefined, + })} + > + + {!closed && preToolbarActions} + {!closed && zoomEnabled && ( + + )} + {!closed && toolbar} + setClosed(p => !p)} + > + {closed ? ( + + ) : ( + + )} + + + + ) : null} + +
+ {canvas} + {annotations ? ( + + ) : null} + {typeof children === 'function' + ? children({annotationsOverlayRef}) + : children} +
+
+
+ )} +
+ + ); +} diff --git a/databox/client/src/components/Media/Asset/Players/ImagePlayer.tsx b/databox/client/src/components/Media/Asset/Players/ImagePlayer.tsx new file mode 100644 index 000000000..1ea89fe94 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Players/ImagePlayer.tsx @@ -0,0 +1,61 @@ +import {AssetAnnotationHandle} from '../Annotations/AssetAnnotationsOverlay.tsx'; +import {File} from '../../../../types.ts'; +import {PlayerProps} from './index.ts'; +import {AssetAnnotation} from '../Annotations/annotationTypes.ts'; +import React, {useRef} from 'react'; +import FileToolbar from './FileToolbar.tsx'; + +type Props = { + file: File; + title?: string; + annotations?: AssetAnnotation[] | undefined; +} & PlayerProps; + +export default function ImagePlayer({ + file, + title, + annotations, + onLoad, + onNewAnnotation, + zoomEnabled, + controls, +}: Props) { + const annotationsOverlayRef = useRef(null); + const isSvg = file.type === 'image/svg+xml'; + + const pOnLoad = React.useCallback(() => { + onLoad?.(); + annotationsOverlayRef.current?.render(); + }, [onLoad]); + + React.useEffect(() => { + annotationsOverlayRef.current?.render(); + }, [file]); + + return ( + <> + + {title} + + + ); +} diff --git a/databox/client/src/components/Media/Asset/Players/PDFPlayer.tsx b/databox/client/src/components/Media/Asset/Players/PDFPlayer.tsx index f275fb4e0..e6d006d1f 100644 --- a/databox/client/src/components/Media/Asset/Players/PDFPlayer.tsx +++ b/databox/client/src/components/Media/Asset/Players/PDFPlayer.tsx @@ -1,13 +1,21 @@ -import {useCallback, useContext, useRef, useState} from 'react'; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import {createStrictDimensions, PlayerProps} from './index'; import {Document, Page, pdfjs} from 'react-pdf'; import {getRatioDimensions} from './VideoPlayer'; import {DisplayContext} from '../../DisplayContext'; import 'react-pdf/dist/esm/Page/TextLayer.css'; import 'react-pdf/dist/esm/Page/AnnotationLayer.css'; -import {Box, CircularProgress, IconButton, Stack} from '@mui/material'; +import {CircularProgress, IconButton} from '@mui/material'; import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import {AssetAnnotation} from '../Annotations/annotationTypes.ts'; +import FileToolbar from './FileToolbar.tsx'; type Props = { controls?: boolean | undefined; @@ -18,12 +26,16 @@ export default function PDFPlayer({ controls, dimensions: forcedDimensions, onLoad, + annotations, + onNewAnnotation, + zoomEnabled, }: Props) { const [ratio, setRatio] = useState(); const [numPages, setNumPages] = useState(); - const pageRef = useRef(1); - const [pageNumber, setPageNumberProxy] = useState(1); - const [renderedPageNumber, setRenderedPageNumber] = useState(); + const [pageNumber, setPageNumber] = useState(1); + const [renderedPageNumber, setRenderedPageNumber] = React.useState< + number | undefined + >(); const displayContext = useContext(DisplayContext); const dimensions = createStrictDimensions( forcedDimensions ?? {width: displayContext!.thumbSize} @@ -41,140 +53,103 @@ export default function PDFPlayer({ [onLoad] ); - const setPageNumber = (num: number): void => { - pageRef.current = num; - setPageNumberProxy(num); - }; + useEffect(() => { + if (annotations && annotations.length > 0) { + const goTo = annotations[annotations.length - 1].page; + goTo && setPageNumber(goTo); + } + }, [annotations]); - const prevPageClassName = 'pdf-prev-page'; - const controlsClassName = 'pdf-controls'; - const isLoading = renderedPageNumber !== pageNumber; + const pageAnnotations: AssetAnnotation[] = useMemo( + () => annotations?.filter(a => a.page === pageNumber) ?? [], + [annotations, pageNumber] + ); return ( - - - {ratio ? ( + 0 + ? pageAnnotations + : undefined + } + zoomEnabled={zoomEnabled} + annotationEnabled={true} + page={pageNumber} + preToolbarActions={ + controls ? ( <> - {controls ? ( -
+ setPageNumber(pageNumber - 1)} + disabled={pageNumber === 1} > - {isLoading ? ( -
- -
- ) : ( - '' - )} - -
- ({ - opacity: 0.9, - bgcolor: 'background.paper', - p: 1, - boxShadow: theme.shadows[2], - borderRadius: - theme.shape.borderRadius, - })} - direction={'row'} - alignItems={'center'} - spacing={3} - > - - setPageNumber(pageNumber - 1) - } - disabled={pageNumber === 1} - > - - -
- {pageNumber} / {numPages} -
- - setPageNumber(pageNumber + 1) - } - disabled={pageNumber === numPages} - > - - -
-
-
- ) : ( - '' - )} - - {isLoading && renderedPageNumber ? ( - - ) : ( - '' - )} - { - if (pageRef.current === pageNumber) { - setRenderedPageNumber(pageNumber); - } + + + +
+ > + {pageNumber} / {numPages} +
+
+ setPageNumber(pageNumber + 1)} + disabled={pageNumber === numPages} + > + + +
- ) : ( - '' - )} -
-
+ ) : undefined + } + > + {({annotationsOverlayRef}) => ( +
+ + {ratio ? ( + <> + + +
+ } + onRenderSuccess={() => { + setRenderedPageNumber(pageNumber); + annotationsOverlayRef.current?.render(); + }} + /> + + ) : null} + + + )} + ); } diff --git a/databox/client/src/components/Media/Asset/Players/ToolbarPaper.tsx b/databox/client/src/components/Media/Asset/Players/ToolbarPaper.tsx new file mode 100644 index 000000000..2dd6a7a99 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Players/ToolbarPaper.tsx @@ -0,0 +1,35 @@ +import {annotationZIndex} from '../Annotations/AssetAnnotationsOverlay.tsx'; +import {Paper, PaperProps} from '@mui/material'; +import {PropsWithChildren} from 'react'; + +type Props = PropsWithChildren<{ + annotationActive: boolean; + sx?: PaperProps['sx']; +}>; + +export default function ToolbarPaper({children, annotationActive, sx}: Props) { + return ( + ({ + borderRadius: theme.shape.borderRadius, + position: 'absolute', + zIndex: annotationZIndex + 1, + backgroundColor: `rgba(255, 255, 255, 0.8)`, + alignItems: 'center', + ...(annotationActive + ? { + pointerEvents: 'none', + opacity: 0.6, + } + : {}), + left: '50%', + transform: 'translateX(-50%)', + p: 2, + ...(typeof sx === 'function' ? sx(theme) : sx), + })} + > + {children} + + ); +} diff --git a/databox/client/src/components/Media/Asset/Players/ZoomControls.tsx b/databox/client/src/components/Media/Asset/Players/ZoomControls.tsx new file mode 100644 index 000000000..e0b8490f9 --- /dev/null +++ b/databox/client/src/components/Media/Asset/Players/ZoomControls.tsx @@ -0,0 +1,51 @@ +import {ReactZoomPanPinchHandlers, useControls} from 'react-zoom-pan-pinch'; +import ZoomInIcon from '@mui/icons-material/ZoomIn'; +import ZoomOutIcon from '@mui/icons-material/ZoomOut'; +import {IconButton} from '@mui/material'; +import FitScreenIcon from '@mui/icons-material/FitScreen'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import PanToolIcon from '@mui/icons-material/PanTool'; +import {StateSetter} from '../../../../types.ts'; + +type Props = { + fitContentToWrapper: ( + centerView: ReactZoomPanPinchHandlers['centerView'] + ) => void; + hand: boolean; + setHand: StateSetter; + forceHand: boolean | undefined; +}; + +export default function ZoomControls({ + fitContentToWrapper, + hand, + setHand, + forceHand, +}: Props) { + const {zoomIn, zoomOut, resetTransform, centerView} = useControls(); + + return ( + <> + {!forceHand ? ( + setHand(p => !p)} + > + + + ) : null} + zoomIn()}> + + + zoomOut()}> + + + resetTransform()}> + + + fitContentToWrapper(centerView)}> + + + + ); +} diff --git a/databox/client/src/components/Media/Asset/Players/index.ts b/databox/client/src/components/Media/Asset/Players/index.ts index caac8ca26..d0952cc76 100644 --- a/databox/client/src/components/Media/Asset/Players/index.ts +++ b/databox/client/src/components/Media/Asset/Players/index.ts @@ -1,4 +1,8 @@ import {File} from '../../../../types'; +import { + AssetAnnotation, + OnNewAnnotation, +} from '../Annotations/annotationTypes.ts'; export type FileWithUrl = { url: string; @@ -29,4 +33,11 @@ export type PlayerProps = { dimensions?: Dimensions | undefined; onLoad?: (() => void) | undefined; noInteraction?: boolean | undefined; + zoomEnabled?: boolean | undefined; + title: string | undefined; + controls?: boolean | undefined; + onNewAnnotation?: OnNewAnnotation | undefined; + annotations?: AssetAnnotation[] | undefined; }; + +export const filePlayerRelativeWrapperClassName = 'fprw'; diff --git a/databox/client/src/components/Media/Asset/View/AssetView.tsx b/databox/client/src/components/Media/Asset/View/AssetView.tsx new file mode 100644 index 000000000..085cc61f4 --- /dev/null +++ b/databox/client/src/components/Media/Asset/View/AssetView.tsx @@ -0,0 +1,243 @@ +import React, {FC, useCallback, useMemo, useRef, useState} from 'react'; +import {Asset, AssetRendition} from '../../../../types.ts'; +import {AppDialog} from '@alchemy/phrasea-ui'; +import FilePlayer from '../FilePlayer.tsx'; +import {useWindowSize} from '@alchemy/react-hooks/src/useWindowSize.ts'; +import {StackedModalProps, useParams} from '@alchemy/navigation'; +import {Dimensions, filePlayerRelativeWrapperClassName} from '../Players'; +import {Box} from '@mui/material'; +import FileIntegrations from '../FileIntegrations.tsx'; +import {getAsset} from '../../../../api/asset.ts'; +import FullPageLoader from '../../../Ui/FullPageLoader.tsx'; +import RouteDialog from '../../../Dialog/RouteDialog.tsx'; +import {getAssetRenditions} from '../../../../api/rendition.ts'; +import {scrollbarWidth} from '../../../../constants.ts'; +import AssetAttributes from '../AssetAttributes.tsx'; +import {OnActiveAnnotations} from '../Attribute/Attributes.tsx'; +import {getMediaBackgroundColor} from '../../../../themes/base.ts'; +import {useModalFetch} from '../../../../hooks/useModalFetch.ts'; +import {useChannelRegistration} from '../../../../lib/pusher.ts'; +import {queryClient} from '../../../../lib/query.ts'; +import AssetDiscussion from '../AssetDiscussion.tsx'; +import {annotationZIndex} from '../Annotations/AssetAnnotationsOverlay.tsx'; +import { + AssetAnnotation, + OnNewAnnotation, +} from '../Annotations/annotationTypes.ts'; +import AssetViewHeader from './AssetViewHeader.tsx'; + +export type IntegrationOverlayCommonProps = { + dimensions: Dimensions; +}; + +type IntegrationOverlay

= { + component: FC

; + props: P; + replace: boolean; +}; + +export type SetIntegrationOverlayFunction

= ( + component: FC

| null, + props?: P, + replace?: boolean +) => void; + +type Props = {} & StackedModalProps; + +export default function AssetView({modalIndex, open}: Props) { + const menuWidth = 400; + const headerHeight = 60; + const {id: assetId, renditionId} = useParams(); + const previousData = useRef(); + const [annotations, setAnnotations] = React.useState< + AssetAnnotation[] | undefined + >(); + const onNewAnnotationRef = React.useRef(); + + const queryKey = ['assets', assetId]; + + useChannelRegistration(`asset-${assetId}`, `asset_ingested`, () => { + queryClient.invalidateQueries({queryKey}); + }); + + const {data, isSuccess} = useModalFetch({ + queryKey, + staleTime: 2000, + refetchOnWindowFocus: false, + queryFn: () => + Promise.all([ + getAsset(assetId!), + getAssetRenditions(assetId!).then(r => r.result), + ]), + }); + + const onActiveAnnotations = React.useCallback( + annotations => { + setAnnotations(annotations); + }, + [] + ); + + const winSize = useWindowSize(); + const [integrationOverlay, setIntegrationOverlay] = + useState(null); + + const setProxy: SetIntegrationOverlayFunction = useCallback( + (component, props, replace = false) => { + if (!component) { + setIntegrationOverlay(null); + } else { + setIntegrationOverlay({ + component, + props, + replace, + }); + } + }, + [setIntegrationOverlay] + ); + + const dimensions = useMemo(() => { + return { + width: winSize.innerWidth - menuWidth - scrollbarWidth, + height: winSize.innerHeight - headerHeight - 2, + }; + }, [winSize]); + + const onNewAnnotation: OnNewAnnotation = useCallback( + annotation => { + onNewAnnotationRef.current?.(annotation); + }, + [onNewAnnotationRef, assetId] + ); + + const [asset, renditions] = ( + isSuccess ? data : previousData.current ?? [] + ) as DataTuple; + + React.useEffect(() => { + if (data) { + previousData.current = data; + } + }, [data, previousData]); + + if (!isSuccess && !previousData.current) { + if (!open) { + return null; + } + + return ; + } + + const rendition = renditions.find(r => r.id === renditionId); + + return ( + + {({onClose}) => ( + + } + onClose={onClose} + > + {!isSuccess && } + + ({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + overflowY: 'auto', + height: dimensions.height, + width: dimensions.width + scrollbarWidth, + maxWidth: dimensions.width + scrollbarWidth, + backgroundColor: getMediaBackgroundColor(theme), + })} + > + {rendition?.file && + (!integrationOverlay || + !integrationOverlay.replace) && ( + + )} + {integrationOverlay && + React.createElement( + integrationOverlay.component, + { + dimensions, + ...(integrationOverlay.props || {}), + } + )} + + ({ + width: menuWidth, + maxWidth: menuWidth, + borderLeft: `1px solid ${theme.palette.divider}`, + overflowY: 'auto', + height: dimensions.height, + })} + > + + + + {rendition?.file ? ( + + ) : ( + '' + )} + + + + )} + + ); +} + +type DataTuple = [Asset, AssetRendition[]]; diff --git a/databox/client/src/components/Media/Asset/View/AssetViewHeader.tsx b/databox/client/src/components/Media/Asset/View/AssetViewHeader.tsx new file mode 100644 index 000000000..c4bdccc14 --- /dev/null +++ b/databox/client/src/components/Media/Asset/View/AssetViewHeader.tsx @@ -0,0 +1,71 @@ +import AssetViewNavigation from '../AssetViewNavigation.tsx'; +import {Trans} from 'react-i18next'; +import {Select} from '@mui/material'; +import {Asset, AssetRendition} from '../../../../types.ts'; +import MenuItem from '@mui/material/MenuItem'; +import AssetViewActions from '../Actions/AssetViewActions.tsx'; +import {FlexRow} from '@alchemy/phrasea-ui'; +import {useLocation} from '@alchemy/navigation'; +import type {Location} from '@alchemy/navigation'; +import {memo} from 'react'; +import {modalRoutes} from '../../../../routes.ts'; +import {useNavigateToModal} from '../../../Routing/ModalLink.tsx'; +import {AssetContextState} from '../assetTypes.ts'; + +type Props = { + asset: Asset; + rendition: AssetRendition | undefined; + renditions: AssetRendition[]; + displayActions: boolean; +}; + +function AssetViewHeader({ + asset, + rendition, + displayActions, + renditions, +}: Props) { + const {state} = useLocation() as Location; + const navigateToModal = useNavigateToModal(); + const handleRenditionChange = (renditionId: string) => { + navigateToModal(modalRoutes.assets.routes.view, { + id: asset.id, + renditionId, + }); + }; + + return ( + + +

+ {{name}}'} + /> +
+ + sx={{ml: 2}} + label={''} + size={'small'} + value={rendition?.id} + onChange={e => handleRenditionChange(e.target.value)} + > + {renditions.map((r: AssetRendition) => ( + + {r.name} + + ))} + + {displayActions ? ( + + ) : ( + '' + )} + + ); +} + +export default memo(AssetViewHeader); diff --git a/databox/client/src/components/Media/Asset/assetTypes.ts b/databox/client/src/components/Media/Asset/assetTypes.ts new file mode 100644 index 000000000..4436d1fe5 --- /dev/null +++ b/databox/client/src/components/Media/Asset/assetTypes.ts @@ -0,0 +1,3 @@ +export type AssetContextState = { + assetsContext?: [string, string][]; // [assetId, renditionId] +}; diff --git a/databox/client/src/components/Media/Search/ResultProvider.tsx b/databox/client/src/components/Media/Search/ResultProvider.tsx index 75c5367c7..0610b24a1 100644 --- a/databox/client/src/components/Media/Search/ResultProvider.tsx +++ b/databox/client/src/components/Media/Search/ResultProvider.tsx @@ -25,7 +25,7 @@ async function search( result: Asset[]; facets: TFacets; total: number; - next: string | null; + next?: string | null; debug: ESDebug; }> { if (lastController) { diff --git a/databox/client/src/components/Share/CreateShareDialog.tsx b/databox/client/src/components/Share/CreateShareDialog.tsx index b1010dccb..0e2ded884 100644 --- a/databox/client/src/components/Share/CreateShareDialog.tsx +++ b/databox/client/src/components/Share/CreateShareDialog.tsx @@ -5,7 +5,7 @@ import {TextField} from '@mui/material'; import FormDialog from '../Dialog/FormDialog.tsx'; import {StackedModalProps, useModals, useFormPrompt} from '@alchemy/navigation'; import {createAssetShare} from '../../api/asset.ts'; -import {useFormSubmit} from '../../../../../lib/js/api'; +import {useFormSubmit} from '@alchemy/api'; import RemoteErrors from '../Form/RemoteErrors.tsx'; import {normalizeDate} from '../../lib/date.ts'; diff --git a/databox/client/src/components/Share/ShareItem.tsx b/databox/client/src/components/Share/ShareItem.tsx index f9b87b270..af800e19a 100644 --- a/databox/client/src/components/Share/ShareItem.tsx +++ b/databox/client/src/components/Share/ShareItem.tsx @@ -6,7 +6,7 @@ import AccessTimeIcon from '@mui/icons-material/AccessTime'; import {LoadingButton} from '@mui/lab'; import {useTranslation} from 'react-i18next'; import DeleteIcon from '@mui/icons-material/Delete'; -import {FlexRow} from '../../../../../lib/js/phrasea-ui'; +import {FlexRow} from '@alchemy/phrasea-ui'; import {getShareTitle, UrlActions} from './UrlActions.tsx'; import React from 'react'; import {getShareUrl} from './shareUtils.ts'; diff --git a/databox/client/src/components/Share/UrlActions.tsx b/databox/client/src/components/Share/UrlActions.tsx index 2a8625adf..8a4ade2ae 100644 --- a/databox/client/src/components/Share/UrlActions.tsx +++ b/databox/client/src/components/Share/UrlActions.tsx @@ -1,4 +1,4 @@ -import {FlexRow} from '../../../../../lib/js/phrasea-ui'; +import {FlexRow} from '@alchemy/phrasea-ui'; import {IconButton} from '@mui/material'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import {Share} from '../../types.ts'; diff --git a/databox/client/src/components/Ui/PrivacyField.tsx b/databox/client/src/components/Ui/PrivacyField.tsx index 14694e9fd..74bf92deb 100644 --- a/databox/client/src/components/Ui/PrivacyField.tsx +++ b/databox/client/src/components/Ui/PrivacyField.tsx @@ -106,94 +106,110 @@ export default function PrivacyField({ const handlePChange = (e: SelectChangeEvent): void => { const v = e.target.value; setPrivacy(v); - onChange(getAllowedValue(getValue(v, workspaceOnly, auth), inheritedPrivacy)); + onChange( + getAllowedValue(getValue(v, workspaceOnly, auth), inheritedPrivacy) + ); }; const handleWSOnlyChange = ( e: React.ChangeEvent ): void => { setWorkspaceOnly(e.target.checked); - onChange(getAllowedValue(getValue(privacy, e.target.checked, auth), inheritedPrivacy)); + onChange( + getAllowedValue( + getValue(privacy, e.target.checked, auth), + inheritedPrivacy + ) + ); }; const handleAuthChange = (e: React.ChangeEvent): void => { setAuth(e.target.checked); onChange( - getAllowedValue(getValue(privacy, workspaceOnly, e.target.checked), inheritedPrivacy) + getAllowedValue( + getValue(privacy, workspaceOnly, e.target.checked), + inheritedPrivacy + ) ); }; - const workspaceOnlyLocked = !!inheritedPrivacy && getValue(privacy, true, auth) < inheritedPrivacy; - const authLocked = !!inheritedPrivacy && getValue(privacy, workspaceOnly, false) < inheritedPrivacy; + const workspaceOnlyLocked = + !!inheritedPrivacy && getValue(privacy, true, auth) < inheritedPrivacy; + const authLocked = + !!inheritedPrivacy && + getValue(privacy, workspaceOnly, false) < inheritedPrivacy; const label = t('form.privacy.label', 'Privacy'); return ( <> - {!!inheritedPrivacy ? <> - - {t('form.privacy.inherited', 'This collection cannot be more restricted than its parent collection.')} - - : null} - - - {label} - - - label={label} - value={privacy} - onChange={handlePChange} - > - {Object.keys(choices).map(k => { - return ( - 0 && - getKeyValue(k) < inheritedKeyPrivacy - } - > - - - ); - })} - + {inheritedPrivacy ? ( + <> + + {t( + 'form.privacy.inherited', + 'This collection cannot be more restricted than its parent collection.' + )} + + + ) : null} + + {label} + + label={label} + value={privacy} + onChange={handlePChange} + > + {Object.keys(choices).map(k => { + return ( + 0 && + getKeyValue(k) < inheritedKeyPrivacy + } + > + + + ); + })} + - {['private', 'public'].includes(privacy) && ( - - } - label={t( - 'privacy_field.only_visible_to_workspace', - `Only visible to workspace` - )} - labelPlacement="end" - /> - )} - {privacy === 'public' && ( - - } - label={t( - 'privacy_field.user_must_be_authenticated', - `User must be authenticated` - )} - labelPlacement="end" - /> - )} - + {['private', 'public'].includes(privacy) && ( + + } + label={t( + 'privacy_field.only_visible_to_workspace', + `Only visible to workspace` + )} + labelPlacement="end" + /> + )} + {privacy === 'public' && ( + + } + label={t( + 'privacy_field.user_must_be_authenticated', + `User must be authenticated` + )} + labelPlacement="end" + /> + )} + ); } diff --git a/databox/client/src/components/Upload/FileCard.tsx b/databox/client/src/components/Upload/FileCard.tsx index 5f343d8f4..60105db3d 100644 --- a/databox/client/src/components/Upload/FileCard.tsx +++ b/databox/client/src/components/Upload/FileCard.tsx @@ -34,22 +34,24 @@ export default function FileCard({file, onRemove}: Props) { container spacing={2} > - - {[ - 'image/jpeg', - 'image/png', - 'image/bmp', - 'image/gif', - ].includes(file.type) ? ( + + {[ + 'image/jpeg', + 'image/png', + 'image/bmp', + 'image/gif', + ].includes(file.type) ? ( - ) :
} - + /> + )} + ({ onAction?.(); }, onSubstituteFile: () => { - openModal(SubstituteFileDialog, { + openModal(ReplaceAssetSourceDialog, { asset, }); onAction?.(); diff --git a/databox/client/src/routes.ts b/databox/client/src/routes.ts index 4380e48c9..b1ddd84e5 100644 --- a/databox/client/src/routes.ts +++ b/databox/client/src/routes.ts @@ -2,7 +2,7 @@ import App from './components/App'; import WorkspaceDialog from './components/Dialog/Workspace/WorkspaceDialog'; import CollectionDialog from './components/Dialog/Collection/CollectionDialog'; import AssetDialog from './components/Dialog/Asset/AssetDialog'; -import AssetView from './components/Media/Asset/AssetView'; +import AssetView from './components/Media/Asset/View/AssetView.tsx'; import WorkflowView from './components/Workflow/WorkflowView'; import AppAuthorizationCodePage from './components/AppAuthorizationCodePage'; import {compileRoutes} from '@alchemy/navigation'; diff --git a/databox/client/src/types.ts b/databox/client/src/types.ts index ab3ad36e2..5115cee2e 100644 --- a/databox/client/src/types.ts +++ b/databox/client/src/types.ts @@ -2,6 +2,7 @@ import {ApiHydraObjectResponse} from './api/hydra'; import {AttributeType} from './api/attributes'; import type {WithTranslations} from '@alchemy/react-form'; import {Integration} from './components/Integration/types.ts'; +import {AssetAnnotation} from './components/Media/Asset/Annotations/annotationTypes.ts'; type AlternateUrl = { type: string; @@ -47,13 +48,14 @@ export type Share = { export type ESDocumentState = { synced: boolean; data: object; -} +}; export interface Asset extends IPermissions<{ - canEditAttributes: boolean; - canShare: boolean; - }>, Entity { + canEditAttributes: boolean; + canShare: boolean; + }>, + Entity { title?: string | undefined; resolvedTitle?: string; titleHighlight: string | null; @@ -61,6 +63,8 @@ export interface Asset privacy: number; tags: Tag[] | undefined; owner?: User; + threadKey: string; + thread?: Thread | undefined; workspace: Workspace; attributes: Attribute[]; referenceCollection?: Collection | undefined; @@ -208,7 +212,8 @@ export type AttributeEntity = { translations: KeyTranslations; createdAt: string; updatedAt: string; -} & ApiHydraObjectResponse & Entity; +} & ApiHydraObjectResponse & + Entity; export interface Tag extends ApiHydraObjectResponse, WithTranslations, Entity { name: string; @@ -251,6 +256,36 @@ export interface Basket extends IPermissions, Entity { owner?: User; } +export interface Thread extends Entity { + id: string; + key: string; + createdAt: string; +} + +export type MessageAttachment = { + type: string; + content: string; +}; + +export type DeserializedMessageAttachment = { + type: string; + data: Record; +}; + +export interface ThreadMessage extends Entity { + id: string; + content: string; + attachments?: MessageAttachment[]; + author: User; + createdAt: string; + updatedAt: string; + acknowledged?: boolean; + capabilities: { + canDelete: boolean; + canEdit: boolean; + }; +} + export interface BasketAsset extends Entity { asset: Asset; context?: any; @@ -321,19 +356,6 @@ export type StateSetter = (handler: T | ((prev: T) => T)) => void; export type AssetOrAssetContainer = {} & Entity; -export enum AnnotationType { - Point = 'point', - Circle = 'circle', - Rect = 'rect', - Cue = 'cue', - TimeRange = 'time_range', -} - -export type AssetAnnotation = { - type: AnnotationType; - [prop: string]: any; -}; - export interface Entity { id: string; } diff --git a/expose/client/package.json b/expose/client/package.json index b86d760ea..7fc45fb6f 100644 --- a/expose/client/package.json +++ b/expose/client/package.json @@ -8,6 +8,7 @@ "lint": "eslint src", "lint:fix": "eslint --fix src", "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json|cjs|tsx|jsx)\"", + "cs": "pnpm lint:fix && pnpm format", "preview": "vite preview", "translate": "i18next-scanner --config ../../lib/js/i18n/i18next-scanner.config.js" }, diff --git a/lib/js/navigation/src/types.ts b/lib/js/navigation/src/types.ts index 11ba9d2c6..9cc26e170 100644 --- a/lib/js/navigation/src/types.ts +++ b/lib/js/navigation/src/types.ts @@ -1,5 +1,5 @@ import React, {ElementType, FunctionComponent, PropsWithChildren} from "react"; -import {ActionFunction, LoaderFunction} from "react-router-dom"; +import type {ActionFunction, LoaderFunction} from "react-router-dom"; export type RouteDefinition = { path: string; @@ -35,3 +35,10 @@ export type TErrorFallbackComponent = (props: ErrorFallbackProps) => React.JSX.E export type TErrorBoundaryComponent = React.JSXElementConstructor>; + + +export type { + Location, + Path, + To, +} from "react-router-dom"; diff --git a/lib/js/phrasea-ui/index.ts b/lib/js/phrasea-ui/index.ts index 6638568ed..7133909dd 100644 --- a/lib/js/phrasea-ui/index.ts +++ b/lib/js/phrasea-ui/index.ts @@ -5,17 +5,21 @@ import UserMenu from "./src/components/UserMenu"; import FullPageLoader from "./src/components/FullPageLoader"; import AppDialog, {AppDialogProps, AppDialogTitle, BootstrapDialog} from "./src/components/Dialog/AppDialog"; import FlexRow from "./src/components/FlexRow"; +import UserAvatar from "./src/components/UserAvatar"; +import MoreActionsButton from "./src/components/MoreActionsButton"; export { NotFoundPage, ErrorPage, ErrorLayout, UserMenu, + UserAvatar, FullPageLoader, AppDialog, AppDialogTitle, BootstrapDialog, FlexRow, + MoreActionsButton, }; export type { diff --git a/lib/js/phrasea-ui/src/components/Dialog/AppDialog.tsx b/lib/js/phrasea-ui/src/components/Dialog/AppDialog.tsx index a4adf00cf..7a735c459 100644 --- a/lib/js/phrasea-ui/src/components/Dialog/AppDialog.tsx +++ b/lib/js/phrasea-ui/src/components/Dialog/AppDialog.tsx @@ -34,7 +34,14 @@ export const AppDialogTitle = (props: DialogTitleProps) => { const {children, onClose, ...other} = props; return ( - + {children} {onClose ? ( ; +type Props = PropsWithChildren<{direction?: CSSProperties['flexDirection']} & BoxProps>; -export default function FlexRow({children, style, ...props}: Props) { +export default function FlexRow({children, style, direction = 'row', ...props}: Props) { return ( any) => MouseEventHandler; + +type Props = { + children: (closeWrapper: CloseWrapper) => (ReactNode | null)[]; +}; + +export default function MoreActionsButton({children}: Props) { + const [anchorEl, setAnchorEl] = React.useState(null); + + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const closeWrapper: CloseWrapper = handler => { + return () => { + handler && handler(); + handleClose(); + }; + }; + + return ( + <> + + + + + {children(closeWrapper).filter(c => null !== c) as ReactNode[]} + + + ); +} diff --git a/lib/js/phrasea-ui/src/components/UserAvatar.tsx b/lib/js/phrasea-ui/src/components/UserAvatar.tsx new file mode 100644 index 000000000..c5c8a1db5 --- /dev/null +++ b/lib/js/phrasea-ui/src/components/UserAvatar.tsx @@ -0,0 +1,66 @@ +import Avatar, {AvatarProps} from "@mui/material/Avatar"; + +type Props = { + size: number; + username: string | undefined | null; +} & AvatarProps; + +const cachedColors: Record = {}; + +function generateHSL(name: string): string { + const getHashOfString = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + hash = Math.abs(hash); + return hash; + }; + + function normalizeHash(hash: number, min: number, max: number) { + return Math.floor((hash % (max - min)) + min); + } + + + const hash = getHashOfString(name); + const h = normalizeHash(hash, 0, 360); + const s = normalizeHash(hash, 50, 75); + const l = normalizeHash(hash, 25, 60); + + return `hsl(${h}, ${s}%, ${l}%)`; +} + +function getUsernameColor(username: string | null | undefined): string { + if (!username) { + username = 'U'; + } + if (cachedColors[username]) { + return cachedColors[username]; + } + cachedColors[username] = generateHSL(username); + + return cachedColors[username]; +} + + +export default function UserAvatar({ + size, + username, + ...props +}: Props) { + + return + {( + username ? username[0] : 'U' + ).toUpperCase()} + +} diff --git a/lib/js/phrasea-ui/src/components/UserMenu.tsx b/lib/js/phrasea-ui/src/components/UserMenu.tsx index 0adbf4196..2dffaa325 100644 --- a/lib/js/phrasea-ui/src/components/UserMenu.tsx +++ b/lib/js/phrasea-ui/src/components/UserMenu.tsx @@ -1,13 +1,13 @@ import React, {ReactNode} from 'react'; import IconButton from '@mui/material/IconButton'; import Menu from '@mui/material/Menu'; -import Avatar from '@mui/material/Avatar'; import Tooltip from '@mui/material/Tooltip'; import MenuItem from '@mui/material/MenuItem'; import {Divider, ListItemIcon, ListItemText} from '@mui/material'; import LogoutIcon from '@mui/icons-material/Logout'; import AccountBoxIcon from '@mui/icons-material/AccountBox'; import {useTranslation} from 'react-i18next'; +import UserAvatar from "./UserAvatar"; type Props = { actions?: (props: { @@ -87,19 +87,10 @@ export default function UserMenu({ onClick={handleOpenUserMenu} sx={{p: 0}} > - - {( - username[0] || 'U' - ).toUpperCase()} - + void; disabled?: boolean; readOnly?: boolean; + displayField?: boolean; label?: TextFieldProps['label']; }; @@ -17,35 +18,25 @@ export default function ColorPicker({ onChange, disabled, readOnly, + displayField = true, }: Props) { - const [open, setOpen] = React.useState(false); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl) && !disabled && !readOnly; + const inputRef = React.useRef(); + const handleClose = React.useCallback(() => { + setAnchorEl(null); + }, []); + const toggleOpen = React.useCallback< React.MouseEventHandler >(e => { e.stopPropagation(); - setOpen(p => { - if (!p) { - setTimeout(() => { - if (inputRef.current) { - inputRef.current!.focus(); - } - }, 0); - } - - return !p; - }); - }, []); - const doOpen = React.useCallback< - React.FocusEventHandler - >(() => { - setOpen(true); - }, []); - const doClose = React.useCallback< - React.FocusEventHandler - >(() => { - setOpen(false); + setAnchorEl(p => !p ? e.currentTarget : null); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); }, []); const onTextChange = React.useCallback< React.ChangeEventHandler @@ -60,7 +51,7 @@ export default function ColorPicker({ React.MouseEventHandler >(e => { e.stopPropagation(); - inputRef.current!.focus(); + inputRef.current?.focus(); }, []); const height = 55; @@ -75,18 +66,16 @@ export default function ColorPicker({ cursor: isEditable ? 'pointer' : undefined, }} > - + />} - {open && !disabled && !readOnly && ( -
- -
- )} + + +
); diff --git a/lib/js/react-hooks/src/useWindowSize.ts b/lib/js/react-hooks/src/useWindowSize.ts index 1637ef32a..f6e54b7be 100644 --- a/lib/js/react-hooks/src/useWindowSize.ts +++ b/lib/js/react-hooks/src/useWindowSize.ts @@ -17,14 +17,14 @@ export function useWindowSize(): WindowSize { useEffect(() => { const r = () => { - setSize(getSize()); + requestAnimationFrame(() => setSize(getSize())); }; window.addEventListener('resize', r); return () => { window.removeEventListener('resize', r); }; - }); + }, []); return size; } diff --git a/lib/php/admin-bundle/composer.json b/lib/php/admin-bundle/composer.json index 7b24b3668..504754239 100644 --- a/lib/php/admin-bundle/composer.json +++ b/lib/php/admin-bundle/composer.json @@ -19,7 +19,7 @@ "require": { "php": "^8.3", "alchemy/auth-bundle": "@dev", - "easycorp/easyadmin-bundle": "^4.0", + "easycorp/easyadmin-bundle": "^4.0,<=4.20.2", "guzzlehttp/guzzle": "^7.2", "symfony/framework-bundle": "^6" }, diff --git a/lib/php/notify-bundle/src/AlchemyNotifyBundle.php b/lib/php/notify-bundle/src/AlchemyNotifyBundle.php index 0920d9520..5a8b15ad0 100644 --- a/lib/php/notify-bundle/src/AlchemyNotifyBundle.php +++ b/lib/php/notify-bundle/src/AlchemyNotifyBundle.php @@ -7,6 +7,7 @@ use Alchemy\NotifyBundle\Command\TestNotificationCommand; use Alchemy\NotifyBundle\Notification\NotifierInterface; use Alchemy\NotifyBundle\Notification\SymfonyNotifier; +use Alchemy\NotifyBundle\Service\NovuClient; use Symfony\Component\Config\Definition\Configuration; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\Config\Definition\Processor; @@ -20,7 +21,13 @@ public function configure(DefinitionConfigurator $definition): void { $definition->rootNode() ->children() - ->scalarNode('novu_dsn')->defaultValue('novu://%env(NOVU_SECRET_KEY)%@%env(NOVU_API_HOST)%')->end() + ->arrayNode('novu') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('secret_key')->defaultValue('%env(NOVU_SECRET_KEY)%')->end() + ->scalarNode('api_host')->defaultValue('%env(NOVU_API_HOST)%')->end() + ->end() + ->end() ->end() ; } @@ -31,29 +38,33 @@ public function prependExtension(ContainerConfigurator $container, ContainerBuil $configs = $builder->getExtensionConfig($extension->getAlias()); $processor = new Processor(); $config = $processor->processConfiguration(new Configuration($this, $builder, $extension->getAlias()), $configs); + $novuConfig = $config['novu']; $builder->prependExtensionConfig('framework', [ 'notifier' => [ 'texter_transports' => [ - 'novu' => $config['novu_dsn'], + 'novu' => sprintf('novu://%s@%s', $novuConfig['secret_key'], $novuConfig['api_host']), ], 'channel_policy' => [ 'high' => 'push', ], - ] + ], + 'http_client' => [ + 'scoped_clients' => [ + 'novu.client' => [ + 'base_uri' => sprintf('https://%s', $novuConfig['api_host']), + 'verify_peer' => '%env(bool:VERIFY_SSL)%', + ], + ], + ], ]); } - protected function getContainerExtensionClass(): string - { - return AlchemyNotifyExtension::class; - } - - public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { $container->parameters() - ->set('%env(NOVU_DSN)%', 'novu://API_KEY@default') + ->set('alchemy_notify.novu.api_host', $config['novu']['api_host']) + ->set('alchemy_notify.novu.secret_key', $config['novu']['secret_key']) ; $services = $container->services(); @@ -62,6 +73,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->autowire() ->autoconfigure(); + $services->set(NovuClient::class); $services->set(SymfonyNotifier::class); $services->alias(NotifierInterface::class, SymfonyNotifier::class); $services->set(TestNotificationCommand::class); diff --git a/lib/php/notify-bundle/src/Notification/NotifierInterface.php b/lib/php/notify-bundle/src/Notification/NotifierInterface.php index 21f853279..c25a575ea 100644 --- a/lib/php/notify-bundle/src/Notification/NotifierInterface.php +++ b/lib/php/notify-bundle/src/Notification/NotifierInterface.php @@ -17,4 +17,21 @@ public function sendEmail( string $notificationId, array $parameters = [], ): void; + + public function notifyTopic( + string $topicKey, + ?string $authorId, + string $notificationId, + array $parameters = [], + ): void; + + public function addTopicSubscribers( + string $topicKey, + array $subscribers, + ): void; + + public function removeTopicSubscribers( + string $topicKey, + array $subscribers, + ): void; } diff --git a/lib/php/notify-bundle/src/Notification/SymfonyNotifier.php b/lib/php/notify-bundle/src/Notification/SymfonyNotifier.php index 2d9bb82ca..5c448830c 100644 --- a/lib/php/notify-bundle/src/Notification/SymfonyNotifier.php +++ b/lib/php/notify-bundle/src/Notification/SymfonyNotifier.php @@ -4,6 +4,7 @@ namespace Alchemy\NotifyBundle\Notification; +use Alchemy\NotifyBundle\Service\NovuClient; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; use Symfony\Component\Notifier\Bridge\Novu\NovuSubscriberRecipient; @@ -15,6 +16,7 @@ final class SymfonyNotifier implements NotifierInterface, LoggerAwareInterface public function __construct( private readonly SymfonyNotifierInterface $notifier, + private readonly NovuClient $novuClient, ) { } @@ -43,4 +45,23 @@ private function sendNotification(NovuSubscriberRecipient $recipient, string $no $this->notifier->send($notification, $recipient); } + + public function notifyTopic( + string $topicKey, + ?string $authorId, + string $notificationId, + array $parameters = [], + ): void { + $this->novuClient->notifyTopic($topicKey, $authorId, $notificationId, $parameters); + } + + public function addTopicSubscribers(string $topicKey, array $subscribers): void + { + $this->novuClient->addTopicSubscribers($topicKey, $subscribers); + } + + public function removeTopicSubscribers(string $topicKey, array $subscribers): void + { + $this->novuClient->removeTopicSubscribers($topicKey, $subscribers); + } } diff --git a/lib/php/notify-bundle/src/Service/NovuClient.php b/lib/php/notify-bundle/src/Service/NovuClient.php new file mode 100644 index 000000000..eac3c4fac --- /dev/null +++ b/lib/php/notify-bundle/src/Service/NovuClient.php @@ -0,0 +1,67 @@ +client = $client->withOptions([ + 'headers' => [ + 'Authorization' => 'ApiKey ' . $this->secretKey, + ], + ]); + } + + public function notifyTopic( + string $topicKey, + ?string $authorId, + string $notificationId, + array $parameters = [], + ): void { + $data = [ + 'name' => $notificationId, + 'to' => [ + 'type' => 'Topic', + 'topicKey' => $topicKey, + ], + 'payload' => $parameters, + ]; + + if (null !== $authorId) { + $data['actor'] = ['subscriberId' => $authorId]; + } + + $this->client->request('POST', '/v1/events/trigger', [ + 'json' => $data, + ]); + } + + public function addTopicSubscribers(string $topicKey, array $subscribers): void + { + $this->client->request('POST', sprintf('/v1/topics/%s/subscribers', $topicKey), [ + 'json' => [ + 'subscribers' => $subscribers, + ], + ]); + } + + public function removeTopicSubscribers(string $topicKey, array $subscribers): void + { + $this->client->request('POST', sprintf('/v1/topics/%s/subscribers/removal', $topicKey), [ + 'json' => [ + 'subscribers' => $subscribers, + ], + ]); + } +} diff --git a/novu/bridge/app/api/novu/route.ts b/novu/bridge/app/api/novu/route.ts index e728a7444..54039d5c7 100644 --- a/novu/bridge/app/api/novu/route.ts +++ b/novu/bridge/app/api/novu/route.ts @@ -1,5 +1,6 @@ import { serve } from "@novu/framework/next"; import { + databoxDiscussionNewComment, exposeDownloadLink, exposeZippyDownloadLink, uploaderCommitAcknowledged, @@ -11,5 +12,6 @@ export const { GET, POST, OPTIONS } = serve({ uploaderCommitAcknowledged, exposeZippyDownloadLink, exposeDownloadLink, + databoxDiscussionNewComment, ], }); diff --git a/novu/bridge/app/novu/workflows/databox/databoxDiscussionNewComment.tsx b/novu/bridge/app/novu/workflows/databox/databoxDiscussionNewComment.tsx new file mode 100644 index 000000000..89ca54785 --- /dev/null +++ b/novu/bridge/app/novu/workflows/databox/databoxDiscussionNewComment.tsx @@ -0,0 +1,24 @@ +import {workflow} from "@novu/framework"; +import {z} from "zod"; + +export const databoxDiscussionNewComment = workflow( + "databox-discussion-new-comment", + async ({step, payload}) => { + await step.inApp("In-App Step", async () => { + return { + subject: `New comment on ${payload.object}`, + body: `${payload.author} has commented on ${payload.object}.`, + }; + }); + }, + { + payloadSchema: z.object({ + object: z + .string() + .describe("The object title"), + author: z + .string() + .describe("The author of the message"), + }) + }, +); diff --git a/novu/bridge/app/novu/workflows/databox/index.ts b/novu/bridge/app/novu/workflows/databox/index.ts new file mode 100644 index 000000000..8a4632405 --- /dev/null +++ b/novu/bridge/app/novu/workflows/databox/index.ts @@ -0,0 +1 @@ +export * from "./databoxDiscussionNewComment"; diff --git a/novu/bridge/app/novu/workflows/index.ts b/novu/bridge/app/novu/workflows/index.ts index babfe024d..ca233c1ff 100644 --- a/novu/bridge/app/novu/workflows/index.ts +++ b/novu/bridge/app/novu/workflows/index.ts @@ -1,2 +1,3 @@ export * from "./uploader"; +export * from "./databox"; export * from "./expose"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca7b5bd0e..722834ca4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,12 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@18.3.1) + '@emoji-mart/data': + specifier: ^1.2.1 + version: 1.2.1 + '@emoji-mart/react': + specifier: ^1.1.1 + version: 1.1.1(emoji-mart@5.6.0)(react@18.3.1) '@emotion/react': specifier: ^11.13.3 version: 11.13.3(@types/react@18.3.11)(react@18.3.1) @@ -192,6 +198,9 @@ importers: country-locale-map: specifier: ^1.9.8 version: 1.9.8 + emoji-mart: + specifier: ^5.6.0 + version: 5.6.0 flag-icons: specifier: ^6.15.0 version: 6.15.0 @@ -276,6 +285,9 @@ importers: react-virtualized: specifier: ^9.22.5 version: 9.22.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-zoom-pan-pinch: + specifier: ^3.6.1 + version: 3.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) sass: specifier: ^1.79.4 version: 1.79.4 @@ -2215,6 +2227,15 @@ packages: peerDependencies: react: '>=16.8.0' + '@emoji-mart/data@1.2.1': + resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} + + '@emoji-mart/react@1.1.1': + resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==} + peerDependencies: + emoji-mart: ^5.2 + react: ^16.8 || ^17 || ^18 + '@emotion/babel-plugin@11.12.0': resolution: {integrity: sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==} @@ -6668,6 +6689,9 @@ packages: resolution: {integrity: sha512-B+ZM+RXvRqQaAmkMlO/oSe5nMUOaUnyfGYCEHoR8wrXsZR2mA0XVibsxV1bvTwxdRWah1PkQqso2EzhILGHtEQ==} engines: {node: '>=0.10.0'} + emoji-mart@5.6.0: + resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + emoji-regex@7.0.3: resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==} @@ -10746,6 +10770,13 @@ packages: react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 + react-zoom-pan-pinch@3.6.1: + resolution: {integrity: sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: '*' + react-dom: '*' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -14247,6 +14278,13 @@ snapshots: react: 18.3.1 tslib: 2.7.0 + '@emoji-mart/data@1.2.1': {} + + '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.3.1)': + dependencies: + emoji-mart: 5.6.0 + react: 18.3.1 + '@emotion/babel-plugin@11.12.0': dependencies: '@babel/helper-module-imports': 7.25.7 @@ -19544,6 +19582,8 @@ snapshots: elegant-spinner@1.0.1: {} + emoji-mart@5.6.0: {} + emoji-regex@7.0.3: {} emoji-regex@8.0.0: {} @@ -19843,7 +19883,7 @@ snapshots: '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 - cross-spawn: 7.0.3 + cross-spawn: 7.0.5 debug: 4.3.7(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 @@ -24341,6 +24381,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-lifecycles-compat: 3.0.4 + react-zoom-pan-pinch@3.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -25957,7 +26002,7 @@ snapshots: tsutils@3.21.0(typescript@5.6.2): dependencies: - tslib: 1.9.0 + tslib: 1.14.1 typescript: 5.6.2 tui-code-snippet@2.3.3: {} diff --git a/uploader/client/package.json b/uploader/client/package.json index c7e187622..84ff3194d 100644 --- a/uploader/client/package.json +++ b/uploader/client/package.json @@ -8,6 +8,7 @@ "lint": "eslint src", "lint:fix": "eslint --fix src", "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json|cjs|tsx|jsx)\"", + "cs": "pnpm lint:fix && pnpm format", "preview": "vite preview", "translate": "i18next-scanner --config ../../lib/js/i18n/i18next-scanner.config.js" },