diff --git a/Classes/Controller/BackendController.php b/Classes/Controller/BackendController.php index bcbc11e625..33becc51a7 100644 --- a/Classes/Controller/BackendController.php +++ b/Classes/Controller/BackendController.php @@ -12,18 +12,13 @@ * source code. */ -use Neos\ContentRepository\Core\NodeType\NodeTypeName; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Flow\Mvc\Controller\ActionController; -use Neos\Flow\Mvc\View\ViewInterface; use Neos\Flow\Persistence\PersistenceManagerInterface; -use Neos\Flow\ResourceManagement\ResourceManager; use Neos\Flow\Security\Context; use Neos\Flow\Session\SessionInterface; -use Neos\Fusion\View\FusionView; -use Neos\Neos\Controller\Backend\MenuHelper; use Neos\Neos\Domain\Repository\DomainRepository; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; @@ -32,13 +27,18 @@ use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\Service\BackendRedirectionService; use Neos\Neos\Service\UserService; -use Neos\Neos\Ui\Domain\Service\StyleAndJavascriptInclusionService; -use Neos\Neos\Ui\Service\NodeClipboard; +use Neos\Neos\Ui\Domain\ConfigurationProviderInterface; +use Neos\Neos\Ui\Domain\FrontendConfigurationProviderInterface; +use Neos\Neos\Ui\Domain\InitialStateProviderInterface; +use Neos\Neos\Ui\Domain\MenuProviderInterface; +use Neos\Neos\Ui\Domain\NodeTypesProviderInterface; +use Neos\Neos\Ui\Domain\RoutesProviderInterface; +use Neos\Neos\Ui\Presentation\ApplicationView; class BackendController extends ActionController { /** - * @var FusionView + * @var ApplicationView */ protected $view; @@ -72,53 +72,59 @@ class BackendController extends ActionController */ protected $session; + /** + * @Flow\Inject(lazy=false) + * @var BackendRedirectionService + */ + protected $backendRedirectionService; + /** * @Flow\Inject - * @var ResourceManager + * @var ContentRepositoryRegistry */ - protected $resourceManager; + protected $contentRepositoryRegistry; /** * @Flow\Inject - * @var MenuHelper + * @var Context */ - protected $menuHelper; + protected $securityContext; /** - * @Flow\Inject(lazy=false) - * @var BackendRedirectionService + * @Flow\Inject + * @var ConfigurationProviderInterface */ - protected $backendRedirectionService; + protected $configurationProvider; /** * @Flow\Inject - * @var ContentRepositoryRegistry + * @var RoutesProviderInterface */ - protected $contentRepositoryRegistry; + protected $routesProvider; /** * @Flow\Inject - * @var Context + * @var FrontendConfigurationProviderInterface */ - protected $securityContext; + protected $frontendConfigurationProvider; /** * @Flow\Inject - * @var StyleAndJavascriptInclusionService + * @var NodeTypesProviderInterface */ - protected $styleAndJavascriptInclusionService; + protected $nodeTypesProvider; /** * @Flow\Inject - * @var NodeClipboard + * @var MenuProviderInterface */ - protected $clipboard; + protected $menuProvider; /** - * @Flow\InjectConfiguration(package="Neos.Neos.Ui", path="splashScreen.partial") - * @var string + * @Flow\Inject + * @var InitialStateProviderInterface */ - protected $splashScreenPartial; + protected $initialStateProvider; /** * Displays the backend interface @@ -181,22 +187,35 @@ public function indexAction(string $node = null) $node = $subgraph->findNodeById($nodeAddress->nodeAggregateId); } - $this->view->assign('user', $user); - $this->view->assign('documentNode', $node); - $this->view->assign('site', $siteNode); - $this->view->assign('clipboardNodes', $this->clipboard->getSerializedNodeAddresses()); - $this->view->assign('clipboardMode', $this->clipboard->getMode()); - $this->view->assign('headScripts', $this->styleAndJavascriptInclusionService->getHeadScripts()); - $this->view->assign('headStylesheets', $this->styleAndJavascriptInclusionService->getHeadStylesheets()); - $this->view->assign('sitesForMenu', $this->menuHelper->buildSiteList($this->getControllerContext())); - $this->view->assign('modulesForMenu', $this->menuHelper->buildModuleList($this->getControllerContext())); - $this->view->assign('contentRepositoryId', $siteDetectionResult->contentRepositoryId); - $this->view->assignMultiple([ - 'subgraph' => $subgraph + 'configuration' => + $this->configurationProvider->getConfiguration( + contentRepository: $contentRepository, + uriBuilder: $this->controllerContext->getUriBuilder(), + ), + 'routes' => + $this->routesProvider->getRoutes( + uriBuilder: $this->controllerContext->getUriBuilder() + ), + 'frontendConfiguration' => + $this->frontendConfigurationProvider->getFrontendConfiguration( + controllerContext: $this->controllerContext, + ), + 'nodeTypes' => + $this->nodeTypesProvider->getNodeTypes(), + 'menu' => + $this->menuProvider->getMenu( + controllerContext: $this->controllerContext, + ), + 'initialState' => + $this->initialStateProvider->getInitialState( + controllerContext: $this->controllerContext, + contentRepositoryId: $siteDetectionResult->contentRepositoryId, + documentNode: $node, + site: $siteNode, + user: $user, + ), ]); - - $this->view->assign('interfaceLanguage', $this->userService->getInterfaceLanguage()); } /** diff --git a/Classes/Domain/CacheConfigurationVersionProviderInterface.php b/Classes/Domain/CacheConfigurationVersionProviderInterface.php new file mode 100644 index 0000000000..253a672a56 --- /dev/null +++ b/Classes/Domain/CacheConfigurationVersionProviderInterface.php @@ -0,0 +1,23 @@ +computedCacheConfigurationVersion ??= + $this->computeCacheConfigurationVersion(); + } + + private function computeCacheConfigurationVersion(): string + { + /** @var ?Account $account */ + $account = $this->securityContext->getAccount(); + + // Get all roles and sort them by identifier + $roles = $account ? array_map(static fn ($role) => $role->getIdentifier(), $account->getRoles()) : []; + sort($roles); + + // Use the roles combination as cache key to allow multiple users sharing the same configuration version + $configurationIdentifier = md5(implode('_', $roles)); + $cacheKey = 'ConfigurationVersion_' . $configurationIdentifier; + /** @var string|false $version */ + $version = $this->configurationCache->get($cacheKey); + + if ($version === false) { + $version = (string)time(); + $this->configurationCache->set($cacheKey, $version); + } + return $configurationIdentifier . '_' . $version; + } +} diff --git a/Classes/Infrastructure/Configuration/ConfigurationProvider.php b/Classes/Infrastructure/Configuration/ConfigurationProvider.php new file mode 100644 index 0000000000..87d130525a --- /dev/null +++ b/Classes/Infrastructure/Configuration/ConfigurationProvider.php @@ -0,0 +1,93 @@ + $this->configurationManager->getConfiguration( + ConfigurationManager::CONFIGURATION_TYPE_SETTINGS, + 'Neos.Neos.userInterface.navigateComponent.nodeTree', + ), + 'structureTree' => $this->configurationManager->getConfiguration( + ConfigurationManager::CONFIGURATION_TYPE_SETTINGS, + 'Neos.Neos.userInterface.navigateComponent.structureTree', + ), + 'allowedTargetWorkspaces' => + $this->workspaceService->getAllowedTargetWorkspaces( + $contentRepository + ), + 'endpoints' => [ + 'nodeTypeSchema' => $uriBuilder->reset() + ->setCreateAbsoluteUri(true) + ->uriFor( + actionName: 'nodeTypeSchema', + controllerArguments: [ + 'version' => + $this->cacheConfigurationVersionProvider + ->getCacheConfigurationVersion(), + ], + controllerName: 'Backend\\Schema', + packageKey: 'Neos.Neos', + ), + 'translations' => $uriBuilder->reset() + ->setCreateAbsoluteUri(true) + ->uriFor( + actionName: 'xliffAsJson', + controllerArguments: [ + 'locale' => + $this->userService + ->getInterfaceLanguage(), + 'version' => + $this->cacheConfigurationVersionProvider + ->getCacheConfigurationVersion(), + ], + controllerName: 'Backend\\Backend', + packageKey: 'Neos.Neos', + ), + ] + ]; + } +} diff --git a/Classes/Infrastructure/Configuration/FrontendConfigurationProvider.php b/Classes/Infrastructure/Configuration/FrontendConfigurationProvider.php new file mode 100644 index 0000000000..b4f007f366 --- /dev/null +++ b/Classes/Infrastructure/Configuration/FrontendConfigurationProvider.php @@ -0,0 +1,44 @@ + */ + #[Flow\InjectConfiguration('frontendConfiguration')] + protected array $frontendConfigurationBeforeProcessing; + + /** @return array */ + public function getFrontendConfiguration( + ControllerContext $controllerContext + ): array { + return $this->configurationRenderingService->computeConfiguration( + $this->frontendConfigurationBeforeProcessing, + ['controllerContext' => $controllerContext] + ); + } +} diff --git a/Classes/Infrastructure/Configuration/InitialStateProvider.php b/Classes/Infrastructure/Configuration/InitialStateProvider.php new file mode 100644 index 0000000000..9851377f3c --- /dev/null +++ b/Classes/Infrastructure/Configuration/InitialStateProvider.php @@ -0,0 +1,59 @@ + */ + #[Flow\InjectConfiguration('initialState')] + protected array $initialStateBeforeProcessing; + + public function getInitialState( + ControllerContext $controllerContext, + ContentRepositoryId $contentRepositoryId, + ?Node $documentNode, + ?Node $site, + User $user, + ): array { + return $this->configurationRenderingService->computeConfiguration( + $this->initialStateBeforeProcessing, + [ + 'controllerContext' => $controllerContext, + 'contentRepositoryId' => $contentRepositoryId, + 'documentNode' => $documentNode, + 'site' => $site, + 'user' => $user, + 'clipboardNodes' => $this->clipboard->getSerializedNodeAddresses(), + 'clipboardMode' => $this->clipboard->getMode(), + ] + ); + } +} diff --git a/Classes/Infrastructure/ContentRepository/NodeTypesProvider.php b/Classes/Infrastructure/ContentRepository/NodeTypesProvider.php new file mode 100644 index 0000000000..12771f8b54 --- /dev/null +++ b/Classes/Infrastructure/ContentRepository/NodeTypesProvider.php @@ -0,0 +1,38 @@ + */ + #[Flow\InjectConfiguration(path: 'nodeTypeRoles')] + protected array $roles; + + /** @var array */ + #[Flow\InjectConfiguration(path: 'nodeTypes.groups', package: 'Neos.Neos')] + protected array $groups; + + public function getNodeTypes(): array + { + return [ + 'roles' => $this->roles, + 'groups' => $this->groups, + ]; + } +} diff --git a/Classes/Infrastructure/MVC/RoutesProvider.php b/Classes/Infrastructure/MVC/RoutesProvider.php new file mode 100644 index 0000000000..4303b5ae3b --- /dev/null +++ b/Classes/Infrastructure/MVC/RoutesProvider.php @@ -0,0 +1,177 @@ + + $helper->buildUiServiceRoute('change'), + 'publish' => + $helper->buildUiServiceRoute('publish'), + 'discard' => + $helper->buildUiServiceRoute('discard'), + 'changeBaseWorkspace' => + $helper->buildUiServiceRoute('changeBaseWorkspace'), + 'rebaseWorkspace' => + $helper->buildUiServiceRoute('rebaseWorkspace'), + 'copyNodes' => + $helper->buildUiServiceRoute('copyNodes'), + 'cutNodes' => + $helper->buildUiServiceRoute('cutNodes'), + 'clearClipboard' => + $helper->buildUiServiceRoute('clearClipboard'), + 'flowQuery' => + $helper->buildUiServiceRoute('flowQuery'), + 'generateUriPathSegment' => + $helper->buildUiServiceRoute('generateUriPathSegment'), + 'getWorkspaceInfo' => + $helper->buildUiServiceRoute('getWorkspaceInfo'), + 'getAdditionalNodeMetadata' => + $helper->buildUiServiceRoute('getAdditionalNodeMetadata'), + ]; + + $routes['core']['content'] = [ + 'imageWithMetadata' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Content', + actionName: 'imageWithMetaData' + ), + 'createImageVariant' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Content', + actionName: 'createImageVariant' + ), + 'loadMasterPlugins' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Content', + actionName: 'masterPlugins' + ), + 'loadPluginViews' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Content', + actionName: 'pluginViews' + ), + 'uploadAsset' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Content', + actionName: 'uploadAsset' + ), + ]; + + $routes['core']['service'] = [ + 'assetProxies' => + $helper->buildCoreRoute( + controllerName: 'Service\\AssetProxies', + actionName: 'index' + ), + 'assets' => + $helper->buildCoreRoute( + controllerName: 'Service\\Assets', + actionName: 'index' + ), + 'nodes' => + $helper->buildCoreRoute( + controllerName: 'Service\\Nodes', + actionName: 'index' + ), + 'userPreferences' => + $helper->buildCoreRoute( + subPackageKey: 'Service', + controllerName: 'UserPreference', + actionName: 'index', + format: 'json', + ), + 'dataSource' => + $helper->buildCoreRoute( + subPackageKey: 'Service', + controllerName: 'DataSource', + actionName: 'index', + format: 'json', + ), + 'contentDimensions' => + $helper->buildCoreRoute( + controllerName: 'Service\\ContentDimensions', + actionName: 'index', + ), + 'impersonateStatus' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Impersonate', + actionName: 'status', + format: 'json', + ), + 'impersonateRestore' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Impersonate', + actionName: 'restoreWithResponse', + format: 'json', + ), + ]; + + $routes['core']['modules'] = [ + 'workspaces' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Module', + actionName: 'index', + arguments: [ + 'module' => 'management/workspaces' + ] + ), + 'userSettings' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Module', + actionName: 'index', + arguments: [ + 'module' => 'user/usersettings' + ] + ), + 'mediaBrowser' => + $helper->buildCoreRoute( + controllerName: 'Backend\\Module', + actionName: 'index', + arguments: [ + 'module' => 'media/browser' + ] + ), + ]; + + $routes['core']['login'] = + $helper->buildCoreRoute( + controllerName: 'Login', + actionName: 'index', + format: 'json', + ); + + $routes['core']['logout'] = + $helper->buildCoreRoute( + controllerName: 'Login', + actionName: 'logout', + ); + + return $routes; + } +} diff --git a/Classes/Infrastructure/MVC/RoutesProviderHelper.php b/Classes/Infrastructure/MVC/RoutesProviderHelper.php new file mode 100644 index 0000000000..6e91883336 --- /dev/null +++ b/Classes/Infrastructure/MVC/RoutesProviderHelper.php @@ -0,0 +1,65 @@ +uriBuilder->reset() + ->setCreateAbsoluteUri(true) + ->uriFor( + actionName: $actionName, + controllerArguments: [], + controllerName: 'BackendService', + packageKey: 'Neos.Neos.Ui', + ); + } + + public function buildCoreRoute( + string $controllerName, + string $actionName, + ?string $subPackageKey = null, + ?string $format = null, + array $arguments = [], + ): string { + $this->uriBuilder->reset() + ->setCreateAbsoluteUri(true); + + if ($format !== null) { + $this->uriBuilder->setFormat($format); + } + + return $this->uriBuilder->uriFor( + actionName: $actionName, + controllerArguments: $arguments, + controllerName: $controllerName, + packageKey: 'Neos.Neos', + subPackageKey: $subPackageKey, + ); + } +} diff --git a/Classes/Infrastructure/Neos/MenuProvider.php b/Classes/Infrastructure/Neos/MenuProvider.php new file mode 100644 index 0000000000..bd6787ca6a --- /dev/null +++ b/Classes/Infrastructure/Neos/MenuProvider.php @@ -0,0 +1,88 @@ +menuHelper->buildModuleList($controllerContext); + + $result = []; + foreach ($modulesForMenu as $moduleName => $module) { + $result[$moduleName]['label'] = $module['label']; + $result[$moduleName]['icon'] = $module['icon']; + $result[$moduleName]['uri'] = $module['uri']; + $result[$moduleName]['target'] = 'Window'; + + $result[$moduleName]['children'] = match ($module['module']) { + 'content' => $this->buildChildrenForSites($controllerContext), + default => $this->buildChildrenForBackendModule($module), + }; + } + + return array_values($result); + } + + private function buildChildrenForSites(ControllerContext $controllerContext): array + { + $sitesForMenu = $this->menuHelper->buildSiteList($controllerContext); + + $result = []; + foreach ($sitesForMenu as $index => $site) { + $result[$index]['icon'] = 'globe'; + $result[$index]['label'] = $site['name']; + $result[$index]['uri'] = $site['uri']; + $result[$index]['target'] = 'Window'; + $result[$index]['isActive'] = $site['active']; + $result[$index]['skipI18n'] = true; + } + + return array_values($result); + } + + private function buildChildrenForBackendModule(array $module): array + { + $result = []; + foreach ($module['submodules'] as $submoduleName => $submodule) { + if ($submodule['hideInMenu'] === true) { + continue; + } + + $result[$submoduleName]['icon'] = $submodule['icon']; + $result[$submoduleName]['label'] = $submodule['label']; + $result[$submoduleName]['uri'] = $submodule['uri']; + $result[$submoduleName]['position'] = $submodule['position']; + $result[$submoduleName]['isActive'] = true; + $result[$submoduleName]['target'] = 'Window'; + $result[$submoduleName]['skipI18n'] = false; + } + + $positionalArraySorter = new PositionalArraySorter($result); + $result = $positionalArraySorter->toArray(); + + return array_values($result); + } +} diff --git a/Classes/Presentation/ApplicationView.php b/Classes/Presentation/ApplicationView.php new file mode 100644 index 0000000000..f09b91eaa6 --- /dev/null +++ b/Classes/Presentation/ApplicationView.php @@ -0,0 +1,171 @@ + [null, 'The application title which will be used as the HTML .', 'string'], + ]; + + public function render(): string + { + $result = '<!DOCTYPE html>'; + $result .= '<html lang="' . $this->renderLang() . '">'; + $result .= '<head>'; + $result .= $this->renderHead(); + $result .= '</head>'; + $result .= '<body>'; + $result .= $this->renderBody(); + $result .= '</body>'; + $result .= '</html>'; + + return $result; + } + + private function renderLang(): string + { + return $this->userService->getInterfaceLanguage(); + } + + private function renderHead(): string + { + $result = '<meta charset="UTF-8">'; + $result .= '<meta name="viewport" content="width=device-width, initial-scale=1.0">'; + + $result .= '<title>' . $this->options['title'] . ''; + + $result .= $this->styleAndJavascriptInclusionService->getHeadStylesheets(); + $result .= $this->styleAndJavascriptInclusionService->getHeadScripts(); + + $result .= sprintf( + '', + $this->resourceManager->getPublicPackageResourceUriByPath( + 'resource://Neos.Neos.Ui/Public/Images/apple-touch-icon.png' + ) + ); + $result .= sprintf( + '', + $this->resourceManager->getPublicPackageResourceUriByPath( + 'resource://Neos.Neos.Ui/Public/Images/favicon-16x16.png' + ) + ); + $result .= sprintf( + '', + $this->resourceManager->getPublicPackageResourceUriByPath( + 'resource://Neos.Neos.Ui/Public/Images/favicon-32x32.png' + ) + ); + $result .= sprintf( + '', + $this->resourceManager->getPublicPackageResourceUriByPath( + 'resource://Neos.Neos.Ui/Public/Images/safari-pinned-tab.svg' + ) + ); + + $result .= sprintf( + '', + json_encode($this->variables), + ); + + return $result; + } + + private function renderBody(): string + { + $result = sprintf( + '
', + $this->securityContext->getCsrfProtectionToken(), + (string) $this->bootstrap->getContext(), + ); + $result .= $this->renderSplashScreen(); + $result .= '
'; + + return $result; + } + + private function renderSplashScreen(): string + { + return << + @keyframes color_change { + 0% { + filter: drop-shadow(0 0 0 #00adee) opacity(25%); + } + 100% { + filter: drop-shadow(0 0 5px #00adee) opacity(100%); + } + } + + .loadingIcon { + color: #00adee; + animation-name: color_change; + animation-duration: 1.2s; + animation-iteration-count: infinite; + animation-direction: alternate; + animation-timing-function: ease-in-out; + } + .splash { + width: 100vw; + height: 100vh; + background-color: #222222; + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + } + +
+ +
+ HTML; + } +} diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml new file mode 100644 index 0000000000..83e90f7560 --- /dev/null +++ b/Configuration/Objects.yaml @@ -0,0 +1,24 @@ +Neos\Neos\Ui\Controller\BackendController: + properties: + configurationProvider: + object: Neos\Neos\Ui\Infrastructure\Configuration\ConfigurationProvider + routesProvider: + object: Neos\Neos\Ui\Infrastructure\MVC\RoutesProvider + frontendConfigurationProvider: + object: Neos\Neos\Ui\Infrastructure\Configuration\FrontendConfigurationProvider + nodeTypesProvider: + object: Neos\Neos\Ui\Infrastructure\ContentRepository\NodeTypesProvider + menuProvider: + object: Neos\Neos\Ui\Infrastructure\Neos\MenuProvider + initialStateProvider: + object: Neos\Neos\Ui\Infrastructure\Configuration\InitialStateProvider + +Neos\Neos\Ui\Infrastructure\Cache\CacheConfigurationVersionProvider: + properties: + configurationCache: + object: + factoryObjectName: Neos\Flow\Cache\CacheManager + factoryMethodName: getCache + arguments: + 1: + value: Neos_Neos_Configuration_Version diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index a5aa25d1cf..d545779a1d 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -31,9 +31,6 @@ Neos: Ui: - splashScreen: - partial: 'SplashScreen' - # API: "start 100" and smaller numbers; "no numbers", ... resources: diff --git a/Configuration/Views.yaml b/Configuration/Views.yaml index 1e29893dac..434c5d0fc7 100644 --- a/Configuration/Views.yaml +++ b/Configuration/Views.yaml @@ -1,7 +1,6 @@ - requestFilter: 'isPackage("Neos.Neos.Ui") && isController("Backend")' - viewObjectName: 'Neos\Fusion\View\FusionView' + viewObjectName: 'Neos\Neos\Ui\Presentation\ApplicationView' options: - fusionPathPatterns: - - 'resource://Neos.Neos.Ui/Private/Fusion/Backend' + title: 'Neos CMS'