From 230ae585d290d08a507827de4bf714cdea2a27e9 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:35:59 +0200 Subject: [PATCH 01/20] 4992: Changed how exceptions are handled in InstantBook --- src/Controller/InteractiveController.php | 20 ++++++++-- src/InteractiveSlide/InstantBook.php | 51 +++++++++++++----------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/Controller/InteractiveController.php b/src/Controller/InteractiveController.php index 16ebab476..a872ee0ac 100644 --- a/src/Controller/InteractiveController.php +++ b/src/Controller/InteractiveController.php @@ -7,12 +7,14 @@ use App\Entity\ScreenUser; use App\Entity\Tenant\Slide; use App\Entity\User; -use App\Exceptions\NotFoundException; +use App\Exceptions\InteractiveSlideException; use App\Service\InteractiveSlideService; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; #[AsController] final readonly class InteractiveController @@ -26,14 +28,24 @@ public function __invoke(Request $request, Slide $slide): JsonResponse { $requestBody = $request->toArray(); - $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); + try { + $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); + } catch (InteractiveSlideException $e) { + throw new HttpException($e->getCode(), $e->getMessage()); + } $user = $this->security->getUser(); if (!($user instanceof User || $user instanceof ScreenUser)) { - throw new NotFoundException('User not found'); + throw new NotFoundHttpException('User not found'); + } + + try { + $actionResult = $this->interactiveSlideService->performAction($user, $slide, $interactionRequest); + } catch (InteractiveSlideException $e) { + throw new HttpException($e->getCode(), $e->getMessage()); } - return new JsonResponse($this->interactiveSlideService->performAction($user, $slide, $interactionRequest)); + return new JsonResponse($actionResult); } } diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 62f2f2855..37c8ff035 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -9,14 +9,12 @@ use App\Entity\Tenant\InteractiveSlide; use App\Entity\Tenant\Slide; use App\Entity\User; +use App\Exceptions\InteractiveSlideException; use App\Service\InteractiveSlideService; use App\Service\KeyVaultService; use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Symfony\Component\HttpKernel\Exception\ConflictHttpException; -use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -90,7 +88,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionSlid return match ($interactionRequest->action) { self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new BadRequestHttpException('Action not allowed'), + default => throw new InteractiveSlideException('Action not allowed'), }; } @@ -105,7 +103,7 @@ private function authenticate(array $configuration): array $password = $this->keyValueService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { - throw new BadRequestHttpException('tenantId, clientId, username, password must all be set.'); + throw new InteractiveSlideException('tenantId, clientId, username, password must all be set.'); } $url = self::LOGIN_ENDPOINT.$tenantId.self::OAUTH_PATH; @@ -124,14 +122,14 @@ private function authenticate(array $configuration): array } /** - * @throws InvalidArgumentException + * @throws InteractiveSlideException|InvalidArgumentException */ private function getToken(Tenant $tenant, InteractiveSlide $interactive): string { $configuration = $interactive->getConfiguration(); if (null === $configuration) { - throw new BadRequestHttpException('InteractiveSlide has no configuration'); + throw new InteractiveSlideException('InteractiveSlide has no configuration'); } return $this->interactiveSlideCache->get( @@ -147,14 +145,15 @@ function (CacheItemInterface $item) use ($configuration): mixed { } /** - * @throws \Throwable + * @throws InteractiveSlideException + * @throws InvalidArgumentException */ private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $interactionRequest): array { $resource = $interactionRequest->data['resource'] ?? null; if (null === $resource) { - throw new \Exception('Resource not set.'); + throw new InteractiveSlideException('Resource not set.', 400); } return $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, @@ -168,7 +167,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); if (null === $interactive) { - throw new \Exception('InteractiveSlide not found'); + throw new InteractiveSlideException('InteractiveSlide not found', 400); } // Optional limiting of available resources. @@ -177,11 +176,11 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) $feed = $slide->getFeed(); if (null === $feed) { - throw new \Exception('Slide feed not set.'); + throw new InteractiveSlideException('Slide feed not set.', 400); } if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new \Exception('Resource not in feed resources'); + throw new InteractiveSlideException('Resource not in feed resources', 400); } $token = $this->getToken($tenant, $interactive); @@ -273,7 +272,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { ); if ($lastRequestDateTime->add(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)) > $now) { - throw new ServiceUnavailableHttpException(60); + throw new InteractiveSlideException('Service unavailable', 503); } /** @var User|ScreenUser $activeUser */ @@ -283,7 +282,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); if (null === $interactive) { - throw new BadRequestHttpException('Interactive not found'); + throw new InteractiveSlideException('Interactive not found', 400); } // Optional limiting of available resources. @@ -292,11 +291,11 @@ function (CacheItemInterface $item) use ($now): \DateTime { $feed = $slide->getFeed(); if (null === $feed) { - throw new BadRequestHttpException('Slide feed not set.'); + throw new InteractiveSlideException('Slide feed not set.', 400); } if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new BadRequestHttpException('Resource not in feed resources'); + throw new InteractiveSlideException('Resource not in feed resources', 400); } $token = $this->getToken($tenant, $interactive); @@ -304,7 +303,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { $configuration = $interactive->getConfiguration(); if (null === $configuration) { - throw new BadRequestHttpException('Interactive no configuration'); + throw new InteractiveSlideException('Interactive has no configuration', 400); } $username = $this->keyValueService->getValue($configuration['username']); @@ -315,7 +314,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { // Make sure interval is free. $busyIntervals = $this->getBusyIntervals($token, [$resource], $start, $startPlusDuration); if (count($busyIntervals[$resource]) > 0) { - throw new ConflictHttpException('Interval booked already'); + throw new InteractiveSlideException('Interval booked already', 409); } $requestBody = [ @@ -419,18 +418,21 @@ public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): b return true; } + /** + * @throws InteractiveSlideException + */ private function getValueFromInterval(string $key, InteractionSlideRequest $interactionRequest): string|int { $interval = $interactionRequest->data['interval'] ?? null; if (null === $interval) { - throw new BadRequestHttpException('interval not set.'); + throw new InteractiveSlideException('interval not set.', 400); } $value = $interval[$key] ?? null; if (null === $value) { - throw new BadRequestHttpException("interval.'.$key.' not set."); + throw new InteractiveSlideException("interval.'.$key.' not set.", 400); } return $value; @@ -445,6 +447,9 @@ private function getHeaders(string $token): array ]; } + /** + * @throws InteractiveSlideException + */ private function checkPermission(InteractiveSlide $interactive, string $resource): void { $configuration = $interactive->getConfiguration(); @@ -453,7 +458,7 @@ private function checkPermission(InteractiveSlide $interactive, string $resource $allowedResources = $this->getAllowedResources($interactive); if (!in_array($resource, $allowedResources)) { - throw new \Exception('Not allowed'); + throw new InteractiveSlideException('Not allowed', 403); } } } @@ -468,13 +473,13 @@ private function getAllowedResources(InteractiveSlide $interactive): array $key = $configuration['resourceEndpoint'] ?? null; if (null === $key) { - throw new \Exception('resourceEndpoint not set'); + throw new InteractiveSlideException('resourceEndpoint not set', 400); } $resourceEndpoint = $this->keyValueService->getValue($key); if (null === $resourceEndpoint) { - throw new \Exception('resourceEndpoint value not set'); + throw new InteractiveSlideException('resourceEndpoint value not set', 400); } $response = $this->client->request('GET', $resourceEndpoint); From 15ecfdfd9fb2591731c6b22bf725df88485dc69c Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:42:16 +0200 Subject: [PATCH 02/20] 4992: Added error code --- src/InteractiveSlide/InstantBook.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 37c8ff035..e8b0c1ef1 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -88,7 +88,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionSlid return match ($interactionRequest->action) { self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new InteractiveSlideException('Action not allowed'), + default => throw new InteractiveSlideException('Action not allowed', 400), }; } @@ -103,7 +103,7 @@ private function authenticate(array $configuration): array $password = $this->keyValueService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { - throw new InteractiveSlideException('tenantId, clientId, username, password must all be set.'); + throw new InteractiveSlideException('tenantId, clientId, username, password must all be set.', 400); } $url = self::LOGIN_ENDPOINT.$tenantId.self::OAUTH_PATH; @@ -129,7 +129,7 @@ private function getToken(Tenant $tenant, InteractiveSlide $interactive): string $configuration = $interactive->getConfiguration(); if (null === $configuration) { - throw new InteractiveSlideException('InteractiveSlide has no configuration'); + throw new InteractiveSlideException('InteractiveSlide has no configuration', 400); } return $this->interactiveSlideCache->get( From dab0fd08768d90137f111599524f154a90db3300 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:45:16 +0200 Subject: [PATCH 03/20] 4992: Changed wording --- src/InteractiveSlide/InstantBook.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index e8b0c1ef1..ce71669d0 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -88,7 +88,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionSlid return match ($interactionRequest->action) { self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new InteractiveSlideException('Action not allowed', 400), + default => throw new InteractiveSlideException('Action not supported', 400), }; } From 16922e116eab689997715bda33f18c6e220e98ae Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 7 Aug 2025 07:22:27 +0200 Subject: [PATCH 04/20] 4992: Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 784e7a696..6a25a4add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#260](https://github.com/os2display/display-api-service/pull/260) + - Changed how exceptions are handled in InstantBook. + ## [2.5.1] - 2025-06-23 - [#245](https://github.com/os2display/display-api-service/pull/245) From 194111ee81a1aab8768011cee015d2e1ba8b8af1 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:52:31 +0200 Subject: [PATCH 05/20] 4992: Fixed how none existing resources are handled --- src/InteractiveSlide/InstantBook.php | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index ce71669d0..54b8d6941 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -195,10 +195,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) // Add resource to watchedResources, if not in list. if (!in_array($resource, $watchedResources)) { - $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); - $watchedResources[] = $resource; - $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); } $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); @@ -206,7 +203,11 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) $result = []; // Refresh entries for all watched resources. - foreach ($watchedResources as $watchResource) { + foreach ($watchedResources as $key => $watchResource) { + if (!isset($schedules[$watchResource])) { + unset($watchedResources[$key]); + } + $entry = $this->createEntry($watchResource, $schedules[$watchResource], $startFormatted, $start); if ($watchResource == $resource) { @@ -224,6 +225,9 @@ function (CacheItemInterface $item) use ($entry) { } } + $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); + $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); + return $result; } ); @@ -388,9 +392,16 @@ public function getBusyIntervals(string $token, array $resources, \DateTime $sta $result = []; foreach ($scheduleData as $schedule) { - $scheduleId = $schedule['scheduleId']; + $scheduleId = $schedule['scheduleId'] ?? null; + $scheduleItems = $schedule['scheduleItems'] ?? null; + + if ($scheduleId === null ||$scheduleItems === null) { + continue; + } + $result[$scheduleId] = []; - foreach ($schedule['scheduleItems'] as $scheduleItem) { + + foreach ($scheduleItems as $scheduleItem) { $eventStartArray = $scheduleItem['start']; $eventEndArray = $scheduleItem['end']; From 3ac5c6aa13ff1d2cde71980250f860dc32a2ed8f Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:50:36 +0200 Subject: [PATCH 06/20] 4992: Avoid reporting a lot of errors. Reporting no booking options instead --- public/fixture/resources.json | 6 ++ src/InteractiveSlide/InstantBook.php | 116 +++++++++++++++------------ 2 files changed, 71 insertions(+), 51 deletions(-) create mode 100644 public/fixture/resources.json diff --git a/public/fixture/resources.json b/public/fixture/resources.json new file mode 100644 index 000000000..48f467875 --- /dev/null +++ b/public/fixture/resources.json @@ -0,0 +1,6 @@ +[ + { + "ResourceMail": "DOKK1-Lokale-Test1@aarhus.dk", + "allowInstantBooking": "True" + } +] diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 54b8d6941..5ca6f0ead 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -156,84 +156,94 @@ private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $inte throw new InteractiveSlideException('Resource not set.', 400); } + $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); + $startFormatted = $start->format('c'); + return $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, - function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) { + function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $startFormatted) { $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - /** @var User|ScreenUser $activeUser */ - $activeUser = $this->security->getUser(); - $tenant = $activeUser->getActiveTenant(); + try { + /** @var User|ScreenUser $activeUser */ + $activeUser = $this->security->getUser(); + $tenant = $activeUser->getActiveTenant(); - $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); + $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); - if (null === $interactive) { - throw new InteractiveSlideException('InteractiveSlide not found', 400); - } + if (null === $interactive) { + throw new InteractiveSlideException('InteractiveSlide not found', 400); + } - // Optional limiting of available resources. - $this->checkPermission($interactive, $resource); + // Optional limiting of available resources. + $this->checkPermission($interactive, $resource); - $feed = $slide->getFeed(); + $feed = $slide->getFeed(); - if (null === $feed) { - throw new InteractiveSlideException('Slide feed not set.', 400); - } + if (null === $feed) { + throw new InteractiveSlideException('Slide feed not set.', 400); + } - if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new InteractiveSlideException('Resource not in feed resources', 400); - } + if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { + throw new InteractiveSlideException('Resource not in feed resources', 400); + } - $token = $this->getToken($tenant, $interactive); + $token = $this->getToken($tenant, $interactive); - $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); - $startFormatted = $start->format('c'); + $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); - $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); + // Get resources that are watched for availability. + $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => []); - // Get resources that are watched for availability. - $watchedResources = $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => []); + // Add resource to watchedResources, if not in list. + if (!in_array($resource, $watchedResources)) { + $watchedResources[] = $resource; + } - // Add resource to watchedResources, if not in list. - if (!in_array($resource, $watchedResources)) { - $watchedResources[] = $resource; - } + $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); - $schedules = $this->getBusyIntervals($token, $watchedResources, $start, $startPlus1Hour); + $result = []; - $result = []; + // Refresh entries for all watched resources. + foreach ($watchedResources as $key => $watchResource) { + $schedule = $schedules[$watchResource] ?? null; - // Refresh entries for all watched resources. - foreach ($watchedResources as $key => $watchResource) { - if (!isset($schedules[$watchResource])) { - unset($watchedResources[$key]); - } + if ($schedule == null) { + unset($watchedResources[$key]); + } - $entry = $this->createEntry($watchResource, $schedules[$watchResource], $startFormatted, $start); + $entry = $this->createEntry($watchResource, $startFormatted, $start, $schedule); - if ($watchResource == $resource) { - $result = $entry; - } else { - // Refresh cache entry for resources in watch list that are not handled in current request. - $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); - $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, - function (CacheItemInterface $item) use ($entry) { - $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + if ($watchResource == $resource) { + $result = $entry; + } else { + // Refresh cache entry for resources in watch list that are not handled in current request. + $this->interactiveSlideCache->delete(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource); + $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$watchResource, + function (CacheItemInterface $item) use ($entry) { + $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); - return $entry; - } - ); + return $entry; + } + ); + } } - } - $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); - $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); + $this->interactiveSlideCache->delete(self::CACHE_KEY_RESOURCES); + $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); - return $result; + return $result; + } catch (InteractiveSlideException $e) { + return [ + 'resource' => $resource, + 'from' => $startFormatted, + 'options' => [], + ]; + } } ); } - private function createEntry(string $resource, array $schedules, string $startFormatted, \DateTime $start): array + private function createEntry(string $resource, string $startFormatted, \DateTime $start, array $schedules = null): array { $entry = [ 'resource' => $resource, @@ -241,6 +251,10 @@ private function createEntry(string $resource, array $schedules, string $startFo 'options' => [], ]; + if ($schedules === null) { + return $entry; + } + foreach (self::DURATIONS as $durationMinutes) { $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); From 9c56681db73aaa9a4ceda7b98cad0fb61611ab24 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:54:07 +0200 Subject: [PATCH 07/20] 4992: Applied coding standards --- src/InteractiveSlide/InstantBook.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 5ca6f0ead..bc81a2d74 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -207,7 +207,7 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, foreach ($watchedResources as $key => $watchResource) { $schedule = $schedules[$watchResource] ?? null; - if ($schedule == null) { + if (null == $schedule) { unset($watchedResources[$key]); } @@ -232,7 +232,7 @@ function (CacheItemInterface $item) use ($entry) { $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); return $result; - } catch (InteractiveSlideException $e) { + } catch (InteractiveSlideException) { return [ 'resource' => $resource, 'from' => $startFormatted, @@ -243,7 +243,7 @@ function (CacheItemInterface $item) use ($entry) { ); } - private function createEntry(string $resource, string $startFormatted, \DateTime $start, array $schedules = null): array + private function createEntry(string $resource, string $startFormatted, \DateTime $start, ?array $schedules = null): array { $entry = [ 'resource' => $resource, @@ -251,7 +251,7 @@ private function createEntry(string $resource, string $startFormatted, \DateTime 'options' => [], ]; - if ($schedules === null) { + if (null === $schedules) { return $entry; } @@ -409,7 +409,7 @@ public function getBusyIntervals(string $token, array $resources, \DateTime $sta $scheduleId = $schedule['scheduleId'] ?? null; $scheduleItems = $schedule['scheduleItems'] ?? null; - if ($scheduleId === null ||$scheduleItems === null) { + if (null === $scheduleId || null === $scheduleItems) { continue; } From bab679aef4eb98ace43785ce6af4de0bbfe321da Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:06:26 +0200 Subject: [PATCH 08/20] 4992: Cleanup --- public/fixture/resources.json | 6 ------ src/InteractiveSlide/InstantBook.php | 13 ++++++++----- 2 files changed, 8 insertions(+), 11 deletions(-) delete mode 100644 public/fixture/resources.json diff --git a/public/fixture/resources.json b/public/fixture/resources.json deleted file mode 100644 index 48f467875..000000000 --- a/public/fixture/resources.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "ResourceMail": "DOKK1-Lokale-Test1@aarhus.dk", - "allowInstantBooking": "True" - } -] diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index bc81a2d74..b52c1474c 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -232,7 +232,7 @@ function (CacheItemInterface $item) use ($entry) { $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); return $result; - } catch (InteractiveSlideException) { + } catch (\Exception) { return [ 'resource' => $resource, 'from' => $startFormatted, @@ -368,10 +368,13 @@ function (CacheItemInterface $item) use ($now): \DateTime { $status = $response->getStatusCode(); - return ['status' => $status, 'interval' => [ - 'from' => $start->format('c'), - 'to' => $startPlusDuration->format('c'), - ]]; + return [ + 'status' => $status, + 'interval' => [ + 'from' => $start->format('c'), + 'to' => $startPlusDuration->format('c'), + ], + ]; } /** From 782eab290f1b3a2631c8e3a5fa1fc9f4efb284d3 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:15:14 +0200 Subject: [PATCH 09/20] 4992: Cleaned up how exceptions are mapped to http exceptions --- config/packages/api_platform.yaml | 19 ++ public/fixture/resources.json | 6 + src/Controller/InteractiveController.php | 37 ++-- ...veSlide.php => InteractiveSlideConfig.php} | 3 +- src/Exceptions/ForbiddenException.php | 8 + src/Exceptions/InteractiveSlideException.php | 9 - src/Exceptions/NotAcceptableException.php | 8 + src/Exceptions/TooManyRequestsException.php | 8 + src/InteractiveSlide/InstantBook.php | 171 ++++++++++-------- .../InteractiveSlideInterface.php | 14 +- src/Repository/InteractiveSlideRepository.php | 14 +- src/Service/InteractiveSlideService.php | 41 ++--- 12 files changed, 199 insertions(+), 139 deletions(-) create mode 100644 public/fixture/resources.json rename src/Entity/Tenant/{InteractiveSlide.php => InteractiveSlideConfig.php} (90%) create mode 100644 src/Exceptions/ForbiddenException.php delete mode 100644 src/Exceptions/InteractiveSlideException.php create mode 100644 src/Exceptions/NotAcceptableException.php create mode 100644 src/Exceptions/TooManyRequestsException.php diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 3455a12d1..89ad82cb3 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -50,3 +50,22 @@ api_platform: email: itkdev@mkb.aarhus.dk license: name: MIT + + # @see https://api-platform.com/docs/core/errors/#exception-to-status-configuration-using-symfony + exception_to_status: + # The 4 following handlers are registered by default, keep those lines to prevent unexpected side effects + Symfony\Component\Serializer\Exception\ExceptionInterface: 400 # Use a raw status code (recommended) + ApiPlatform\Exception\InvalidArgumentException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_BAD_REQUEST + ApiPlatform\ParameterValidator\Exception\ValidationExceptionInterface: 400 + Doctrine\ORM\OptimisticLockException: 409 + + # Validation exception + ApiPlatform\Validator\Exception\ValidationException: !php/const Symfony\Component\HttpFoundation\Response::HTTP_UNPROCESSABLE_ENTITY + + # App exception mappings + App\Exceptions\BadRequestException: 400 + App\Exceptions\ForbiddenException: 403 + App\Exceptions\NotFoundException: 404 + App\Exceptions\NotAcceptableException: 406 + App\Exceptions\ConflictException: 409 + App\Exceptions\TooManyRequestsException: 429 diff --git a/public/fixture/resources.json b/public/fixture/resources.json new file mode 100644 index 000000000..48f467875 --- /dev/null +++ b/public/fixture/resources.json @@ -0,0 +1,6 @@ +[ + { + "ResourceMail": "DOKK1-Lokale-Test1@aarhus.dk", + "allowInstantBooking": "True" + } +] diff --git a/src/Controller/InteractiveController.php b/src/Controller/InteractiveController.php index a872ee0ac..d5e5b82f3 100644 --- a/src/Controller/InteractiveController.php +++ b/src/Controller/InteractiveController.php @@ -6,15 +6,16 @@ use App\Entity\ScreenUser; use App\Entity\Tenant\Slide; -use App\Entity\User; -use App\Exceptions\InteractiveSlideException; +use App\Exceptions\BadRequestException; +use App\Exceptions\ConflictException; +use App\Exceptions\NotAcceptableException; +use App\Exceptions\TooManyRequestsException; use App\Service\InteractiveSlideService; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Attribute\AsController; -use Symfony\Component\HttpKernel\Exception\HttpException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; #[AsController] final readonly class InteractiveController @@ -24,27 +25,27 @@ public function __construct( private Security $security, ) {} + /** + * @throws ConflictException + * @throws BadRequestException + * @throws NotAcceptableException + * @throws TooManyRequestsException + */ public function __invoke(Request $request, Slide $slide): JsonResponse { - $requestBody = $request->toArray(); + $user = $this->security->getUser(); - try { - $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); - } catch (InteractiveSlideException $e) { - throw new HttpException($e->getCode(), $e->getMessage()); + if (!($user instanceof ScreenUser)) { + throw new AccessDeniedHttpException('Only screen user can perform action.'); } - $user = $this->security->getUser(); + $tenant = $user->getActiveTenant(); - if (!($user instanceof User || $user instanceof ScreenUser)) { - throw new NotFoundHttpException('User not found'); - } + $requestBody = $request->toArray(); - try { - $actionResult = $this->interactiveSlideService->performAction($user, $slide, $interactionRequest); - } catch (InteractiveSlideException $e) { - throw new HttpException($e->getCode(), $e->getMessage()); - } + $interactionRequest = $this->interactiveSlideService->parseRequestBody($requestBody); + + $actionResult = $this->interactiveSlideService->performAction($tenant, $slide, $interactionRequest); return new JsonResponse($actionResult); } diff --git a/src/Entity/Tenant/InteractiveSlide.php b/src/Entity/Tenant/InteractiveSlideConfig.php similarity index 90% rename from src/Entity/Tenant/InteractiveSlide.php rename to src/Entity/Tenant/InteractiveSlideConfig.php index faf36d679..457d2d2f1 100644 --- a/src/Entity/Tenant/InteractiveSlide.php +++ b/src/Entity/Tenant/InteractiveSlideConfig.php @@ -9,7 +9,8 @@ use Symfony\Component\Serializer\Annotation\Ignore; #[ORM\Entity(repositoryClass: InteractiveSlideRepository::class)] -class InteractiveSlide extends AbstractTenantScopedEntity +#[ORM\Table(name: "interactive_slide")] +class InteractiveSlideConfig extends AbstractTenantScopedEntity { #[Ignore] #[ORM\Column(nullable: true)] diff --git a/src/Exceptions/ForbiddenException.php b/src/Exceptions/ForbiddenException.php new file mode 100644 index 000000000..c8f6c2f75 --- /dev/null +++ b/src/Exceptions/ForbiddenException.php @@ -0,0 +1,8 @@ +action) { - self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), - self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new InteractiveSlideException('Action not supported', 400), + self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($tenant, $slide, $interactionRequest), + self::ACTION_QUICK_BOOK => $this->quickBook($tenant, $slide, $interactionRequest), + default => throw new NotAcceptableException('Action not supported'), }; } /** * @throws \Throwable + * @throws NotAcceptableException */ private function authenticate(array $configuration): array { @@ -103,7 +111,7 @@ private function authenticate(array $configuration): array $password = $this->keyValueService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { - throw new InteractiveSlideException('tenantId, clientId, username, password must all be set.', 400); + throw new NotAcceptableException('tenantId, clientId, username, password must all be set.', 400); } $url = self::LOGIN_ENDPOINT.$tenantId.self::OAUTH_PATH; @@ -122,14 +130,15 @@ private function authenticate(array $configuration): array } /** - * @throws InteractiveSlideException|InvalidArgumentException + * @throws NotAcceptableException + * @throws InvalidArgumentException */ - private function getToken(Tenant $tenant, InteractiveSlide $interactive): string + private function getToken(Tenant $tenant, InteractiveSlideConfig $interactive): string { $configuration = $interactive->getConfiguration(); if (null === $configuration) { - throw new InteractiveSlideException('InteractiveSlide has no configuration', 400); + throw new NotAcceptableException('InteractiveSlide has no configuration'); } return $this->interactiveSlideCache->get( @@ -145,49 +154,45 @@ function (CacheItemInterface $item) use ($configuration): mixed { } /** - * @throws InteractiveSlideException + * @throws BadRequestException * @throws InvalidArgumentException */ - private function getQuickBookOptions(Slide $slide, InteractionSlideRequest $interactionRequest): array + private function getQuickBookOptions(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { $resource = $interactionRequest->data['resource'] ?? null; if (null === $resource) { - throw new InteractiveSlideException('Resource not set.', 400); + throw new BadRequestException('Resource not set.'); } $start = (new \DateTime())->setTimezone(new \DateTimeZone('UTC')); - $startFormatted = $start->format('c'); return $this->interactiveSlideCache->get(self::CACHE_KEY_OPTIONS_PREFIX.$resource, - function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $startFormatted) { + function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, $start, $tenant) { $item->expiresAfter(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_OPTIONS)); + // If any exceptions are thrown we return an empty options entry. try { - /** @var User|ScreenUser $activeUser */ - $activeUser = $this->security->getUser(); - $tenant = $activeUser->getActiveTenant(); - - $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); + $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - if (null === $interactive) { - throw new InteractiveSlideException('InteractiveSlide not found', 400); + if (null === $interactiveSlideConfig) { + throw new NotAcceptableException('InteractiveSlideConfig not found'); } // Optional limiting of available resources. - $this->checkPermission($interactive, $resource); + $this->checkPermission($interactiveSlideConfig, $resource); $feed = $slide->getFeed(); if (null === $feed) { - throw new InteractiveSlideException('Slide feed not set.', 400); + throw new NotAcceptableException('Slide feed not set.'); } if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new InteractiveSlideException('Resource not in feed resources', 400); + throw new NotAcceptableException('Resource not in feed resources'); } - $token = $this->getToken($tenant, $interactive); + $token = $this->getToken($tenant, $interactiveSlideConfig); $startPlus1Hour = (clone $start)->add(new \DateInterval('PT1H'))->setTimezone(new \DateTimeZone('UTC')); @@ -207,11 +212,11 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, foreach ($watchedResources as $key => $watchResource) { $schedule = $schedules[$watchResource] ?? null; - if (null == $schedule) { + if (!isset($schedules[$watchResource])) { unset($watchedResources[$key]); } - $entry = $this->createEntry($watchResource, $startFormatted, $start, $schedule); + $entry = $this->createEntry($watchResource, $start, $schedule); if ($watchResource == $resource) { $result = $entry; @@ -232,19 +237,18 @@ function (CacheItemInterface $item) use ($entry) { $this->interactiveSlideCache->get(self::CACHE_KEY_RESOURCES, fn () => $watchedResources); return $result; - } catch (\Exception) { - return [ - 'resource' => $resource, - 'from' => $startFormatted, - 'options' => [], - ]; + } catch (\Throwable) { + // All errors should result in empty options. + return $this->createEntry($resource, $start); } } ); } - private function createEntry(string $resource, string $startFormatted, \DateTime $start, ?array $schedules = null): array + private function createEntry(string $resource, \DateTime $start, ?array $schedules = null): array { + $startFormatted = $start->format('c'); + $entry = [ 'resource' => $resource, 'from' => $startFormatted, @@ -256,7 +260,11 @@ private function createEntry(string $resource, string $startFormatted, \DateTime } foreach (self::DURATIONS as $durationMinutes) { - $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); + try { + $startPlus = (clone $start)->add(new \DateInterval('PT'.$durationMinutes.'M'))->setTimezone(new \DateTimeZone('UTC')); + } catch (\Exception) { + continue; + } if ($this->intervalFree($schedules, $start, $startPlus)) { $entry['options'][] = [ @@ -270,9 +278,15 @@ private function createEntry(string $resource, string $startFormatted, \DateTime } /** + * @throws TooManyRequestsException + * @throws ConflictException + * @throws BadRequestException + * @throws InvalidArgumentException + * @throws NotAcceptableException + * @throws ForbiddenException * @throws \Throwable */ - private function quickBook(Slide $slide, InteractionSlideRequest $interactionRequest): array + private function quickBook(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { $resource = (string) $this->getValueFromInterval('resource', $interactionRequest); $durationMinutes = $this->getValueFromInterval('durationMinutes', $interactionRequest); @@ -290,38 +304,34 @@ function (CacheItemInterface $item) use ($now): \DateTime { ); if ($lastRequestDateTime->add(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)) > $now) { - throw new InteractiveSlideException('Service unavailable', 503); + throw new TooManyRequestsException('Service unavailable', 503); } - /** @var User|ScreenUser $activeUser */ - $activeUser = $this->security->getUser(); - $tenant = $activeUser->getActiveTenant(); - - $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); + $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); - if (null === $interactive) { - throw new InteractiveSlideException('Interactive not found', 400); + if (null === $interactiveSlideConfig) { + throw new NotAcceptableException('InteractiveSlideConfig not found', 400); } // Optional limiting of available resources. - $this->checkPermission($interactive, $resource); + $this->checkPermission($interactiveSlideConfig, $resource); $feed = $slide->getFeed(); if (null === $feed) { - throw new InteractiveSlideException('Slide feed not set.', 400); + throw new NotAcceptableException('Slide feed not set.'); } if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new InteractiveSlideException('Resource not in feed resources', 400); + throw new NotAcceptableException('Resource not in feed resources'); } - $token = $this->getToken($tenant, $interactive); + $token = $this->getToken($tenant, $interactiveSlideConfig); - $configuration = $interactive->getConfiguration(); + $configuration = $interactiveSlideConfig->getConfiguration(); if (null === $configuration) { - throw new InteractiveSlideException('Interactive has no configuration', 400); + throw new NotAcceptableException('InteractiveSlideConfig has no configuration'); } $username = $this->keyValueService->getValue($configuration['username']); @@ -332,7 +342,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { // Make sure interval is free. $busyIntervals = $this->getBusyIntervals($token, [$resource], $start, $startPlusDuration); if (count($busyIntervals[$resource]) > 0) { - throw new InteractiveSlideException('Interval booked already', 409); + throw new ConflictException('Interval booked already'); } $requestBody = [ @@ -379,10 +389,9 @@ function (CacheItemInterface $item) use ($now): \DateTime { /** * @see https://docs.microsoft.com/en-us/graph/api/calendar-getschedule?view=graph-rest-1.0&tabs=http - * * @throws \Throwable */ - public function getBusyIntervals(string $token, array $resources, \DateTime $startTime, \DateTime $endTime): array + private function getBusyIntervals(string $token, array $resources, \DateTime $startTime, \DateTime $endTime): array { $body = [ 'schedules' => $resources, @@ -435,7 +444,7 @@ public function getBusyIntervals(string $token, array $resources, \DateTime $sta return $result; } - public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool + private function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool { foreach ($schedule as $scheduleEntry) { if (!($scheduleEntry['startTime'] > $to || $scheduleEntry['endTime'] < $from)) { @@ -447,20 +456,20 @@ public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): b } /** - * @throws InteractiveSlideException + * @throws BadRequestException */ private function getValueFromInterval(string $key, InteractionSlideRequest $interactionRequest): string|int { $interval = $interactionRequest->data['interval'] ?? null; if (null === $interval) { - throw new InteractiveSlideException('interval not set.', 400); + throw new BadRequestException('interval not set.'); } $value = $interval[$key] ?? null; if (null === $value) { - throw new InteractiveSlideException("interval.'.$key.' not set.", 400); + throw new BadRequestException("interval.'.$key.' not set.", 400); } return $value; @@ -476,9 +485,11 @@ private function getHeaders(string $token): array } /** - * @throws InteractiveSlideException + * @throws NotAcceptableException + * @throws ForbiddenException + * @throws InvalidArgumentException */ - private function checkPermission(InteractiveSlide $interactive, string $resource): void + private function checkPermission(InteractiveSlideConfig $interactive, string $resource): void { $configuration = $interactive->getConfiguration(); // Optional limiting of available resources. @@ -486,29 +497,33 @@ private function checkPermission(InteractiveSlide $interactive, string $resource $allowedResources = $this->getAllowedResources($interactive); if (!in_array($resource, $allowedResources)) { - throw new InteractiveSlideException('Not allowed', 403); + throw new ForbiddenException('Not allowed'); } } } - private function getAllowedResources(InteractiveSlide $interactive): array + /** + * @throws NotAcceptableException + * @throws InvalidArgumentException + */ + private function getAllowedResources(InteractiveSlideConfig $interactive): array { - return $this->interactiveSlideCache->get(self::CACHE_ALLOWED_RESOURCES_PREFIX.$interactive->getId(), function (CacheItemInterface $item) use ($interactive) { - $item->expiresAfter(60 * 60); + $configuration = $interactive->getConfiguration(); - $configuration = $interactive->getConfiguration(); + $key = $configuration['resourceEndpoint'] ?? null; - $key = $configuration['resourceEndpoint'] ?? null; + if (null === $key) { + throw new NotAcceptableException('resourceEndpoint not set', 400); + } - if (null === $key) { - throw new InteractiveSlideException('resourceEndpoint not set', 400); - } + $resourceEndpoint = $this->keyValueService->getValue($key); - $resourceEndpoint = $this->keyValueService->getValue($key); + if (null === $resourceEndpoint) { + throw new NotAcceptableException('resourceEndpoint value not set', 400); + } - if (null === $resourceEndpoint) { - throw new InteractiveSlideException('resourceEndpoint value not set', 400); - } + return $this->interactiveSlideCache->get(self::CACHE_ALLOWED_RESOURCES_PREFIX.$interactive->getId(), function (CacheItemInterface $item) use ($resourceEndpoint) { + $item->expiresAfter(60 * 60); $response = $this->client->request('GET', $resourceEndpoint); $content = $response->toArray(); diff --git a/src/InteractiveSlide/InteractiveSlideInterface.php b/src/InteractiveSlide/InteractiveSlideInterface.php index 5ba3343d7..df957842e 100644 --- a/src/InteractiveSlide/InteractiveSlideInterface.php +++ b/src/InteractiveSlide/InteractiveSlideInterface.php @@ -4,9 +4,12 @@ namespace App\InteractiveSlide; +use App\Entity\Tenant; use App\Entity\Tenant\Slide; -use App\Exceptions\InteractiveSlideException; -use Symfony\Component\Security\Core\User\UserInterface; +use App\Exceptions\BadRequestException; +use App\Exceptions\ConflictException; +use App\Exceptions\NotAcceptableException; +use App\Exceptions\TooManyRequestsException; interface InteractiveSlideInterface { @@ -15,7 +18,10 @@ public function getConfigOptions(): array; /** * Perform the given InteractionRequest with the given Slide. * - * @throws InteractiveSlideException + * @throws ConflictException + * @throws BadRequestException + * @throws NotAcceptableException + * @throws TooManyRequestsException */ - public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array; + public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array; } diff --git a/src/Repository/InteractiveSlideRepository.php b/src/Repository/InteractiveSlideRepository.php index a9d52ab90..5e263f4bf 100644 --- a/src/Repository/InteractiveSlideRepository.php +++ b/src/Repository/InteractiveSlideRepository.php @@ -4,22 +4,22 @@ namespace App\Repository; -use App\Entity\Tenant\InteractiveSlide; +use App\Entity\Tenant\InteractiveSlideConfig; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends ServiceEntityRepository * - * @method InteractiveSlide|null find($id, $lockMode = null, $lockVersion = null) - * @method InteractiveSlide|null findOneBy(array $criteria, array $orderBy = null) - * @method InteractiveSlide[] findAll() - * @method InteractiveSlide[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * @method InteractiveSlideConfig|null find($id, $lockMode = null, $lockVersion = null) + * @method InteractiveSlideConfig|null findOneBy(array $criteria, array $orderBy = null) + * @method InteractiveSlideConfig[] findAll() + * @method InteractiveSlideConfig[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class InteractiveSlideRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, InteractiveSlide::class); + parent::__construct($registry, InteractiveSlideConfig::class); } } diff --git a/src/Service/InteractiveSlideService.php b/src/Service/InteractiveSlideService.php index e4c8a6804..21460e002 100644 --- a/src/Service/InteractiveSlideService.php +++ b/src/Service/InteractiveSlideService.php @@ -4,17 +4,17 @@ namespace App\Service; -use App\Entity\ScreenUser; use App\Entity\Tenant; -use App\Entity\Tenant\InteractiveSlide; +use App\Entity\Tenant\InteractiveSlideConfig; use App\Entity\Tenant\Slide; -use App\Entity\User; -use App\Exceptions\InteractiveSlideException; +use App\Exceptions\BadRequestException; +use App\Exceptions\ConflictException; +use App\Exceptions\NotAcceptableException; +use App\Exceptions\TooManyRequestsException; use App\InteractiveSlide\InteractionSlideRequest; use App\InteractiveSlide\InteractiveSlideInterface; use App\Repository\InteractiveSlideRepository; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\Security\Core\User\UserInterface; /** * Service for handling Slide interactions. @@ -33,7 +33,7 @@ public function __construct( * * @param array $requestBody the request body from the http request * - * @throws InteractiveSlideException + * @throws BadRequestException */ public function parseRequestBody(array $requestBody): InteractionSlideRequest { @@ -42,7 +42,7 @@ public function parseRequestBody(array $requestBody): InteractionSlideRequest $data = $requestBody['data'] ?? null; if (null === $implementationClass || null === $action || null === $data) { - throw new InteractiveSlideException('implementationClass, action and/or data not set.'); + throw new BadRequestException('implementationClass, action and/or data not set.'); } return new InteractionSlideRequest($implementationClass, $action, $data); @@ -51,27 +51,24 @@ public function parseRequestBody(array $requestBody): InteractionSlideRequest /** * Perform an action for an interactive slide. * - * @throws InteractiveSlideException + * @throws ConflictException + * @throws BadRequestException + * @throws NotAcceptableException + * @throws TooManyRequestsException */ - public function performAction(UserInterface $user, Slide $slide, InteractionSlideRequest $interactionRequest): array + public function performAction(Tenant $tenant, Slide $slide, InteractionSlideRequest $interactionRequest): array { - if (!$user instanceof ScreenUser && !$user instanceof User) { - throw new InteractiveSlideException('User is not supported'); - } - - $tenant = $user->getActiveTenant(); - $implementationClass = $interactionRequest->implementationClass; - $interactive = $this->getInteractiveSlide($tenant, $implementationClass); + $interactive = $this->getInteractiveSlideConfig($tenant, $implementationClass); if (null === $interactive) { - throw new InteractiveSlideException('Interactive slide not found'); + throw new NotAcceptableException('Interactive slide config not found'); } $interactiveImplementation = $this->getImplementation($interactive->getImplementationClass()); - return $interactiveImplementation->performAction($user, $slide, $interactionRequest); + return $interactiveImplementation->performAction($tenant, $slide, $interactionRequest); } /** @@ -91,7 +88,7 @@ public function getConfigurables(): array /** * Find the implementation class. * - * @throws InteractiveSlideException + * @throws BadRequestException */ public function getImplementation(?string $implementationClass): InteractiveSlideInterface { @@ -99,7 +96,7 @@ public function getImplementation(?string $implementationClass): InteractiveSlid $interactiveImplementations = array_filter($asArray, fn ($implementation) => $implementation::class === $implementationClass); if (0 === count($interactiveImplementations)) { - throw new InteractiveSlideException('Interactive implementation class not found'); + throw new BadRequestException('Interactive implementation class not found'); } return $interactiveImplementations[0]; @@ -108,7 +105,7 @@ public function getImplementation(?string $implementationClass): InteractiveSlid /** * Get the interactive slide. */ - public function getInteractiveSlide(Tenant $tenant, string $implementationClass): ?InteractiveSlide + public function getInteractiveSlideConfig(Tenant $tenant, string $implementationClass): ?InteractiveSlideConfig { return $this->interactiveSlideRepository->findOneBy([ 'implementationClass' => $implementationClass, @@ -127,7 +124,7 @@ public function saveConfiguration(Tenant $tenant, string $implementationClass, a ]); if (null === $entry) { - $entry = new InteractiveSlide(); + $entry = new InteractiveSlideConfig(); $entry->setTenant($tenant); $entry->setImplementationClass($implementationClass); From 64ad721fcae6ecae181997096091ee2aa85a45ea Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:23:59 +0200 Subject: [PATCH 10/20] 4992: Applied coding standards --- public/fixture/resources.json | 6 ------ src/Entity/Tenant/InteractiveSlideConfig.php | 2 +- src/Exceptions/ForbiddenException.php | 3 ++- src/Exceptions/NotAcceptableException.php | 3 ++- src/Exceptions/TooManyRequestsException.php | 3 ++- src/InteractiveSlide/InstantBook.php | 1 + 6 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 public/fixture/resources.json diff --git a/public/fixture/resources.json b/public/fixture/resources.json deleted file mode 100644 index 48f467875..000000000 --- a/public/fixture/resources.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "ResourceMail": "DOKK1-Lokale-Test1@aarhus.dk", - "allowInstantBooking": "True" - } -] diff --git a/src/Entity/Tenant/InteractiveSlideConfig.php b/src/Entity/Tenant/InteractiveSlideConfig.php index 457d2d2f1..ea7fa46ab 100644 --- a/src/Entity/Tenant/InteractiveSlideConfig.php +++ b/src/Entity/Tenant/InteractiveSlideConfig.php @@ -9,7 +9,7 @@ use Symfony\Component\Serializer\Annotation\Ignore; #[ORM\Entity(repositoryClass: InteractiveSlideRepository::class)] -#[ORM\Table(name: "interactive_slide")] +#[ORM\Table(name: 'interactive_slide')] class InteractiveSlideConfig extends AbstractTenantScopedEntity { #[Ignore] diff --git a/src/Exceptions/ForbiddenException.php b/src/Exceptions/ForbiddenException.php index c8f6c2f75..74f7e0caa 100644 --- a/src/Exceptions/ForbiddenException.php +++ b/src/Exceptions/ForbiddenException.php @@ -1,8 +1,9 @@ Date: Thu, 21 Aug 2025 11:28:56 +0200 Subject: [PATCH 11/20] 4992: Fixed comment --- src/InteractiveSlide/InstantBook.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index a9fde005a..92aa57d5e 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -179,7 +179,6 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest, throw new NotAcceptableException('InteractiveSlideConfig not found'); } - // Optional limiting of available resources. $this->checkPermission($interactiveSlideConfig, $resource); $feed = $slide->getFeed(); @@ -493,7 +492,8 @@ private function getHeaders(string $token): array private function checkPermission(InteractiveSlideConfig $interactive, string $resource): void { $configuration = $interactive->getConfiguration(); - // Optional limiting of available resources. + + // Will only limit access to resources if list is set up. if (null !== $configuration && !empty($configuration['resourceEndpoint'])) { $allowedResources = $this->getAllowedResources($interactive); From 735823085e4799d392f6b533d132b0240f934616 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:49:37 +0200 Subject: [PATCH 12/20] 4992: Fixed test issue --- src/InteractiveSlide/InstantBook.php | 2 +- tests/Service/InteractiveServiceTest.php | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 92aa57d5e..1a3a91ce7 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -444,7 +444,7 @@ private function getBusyIntervals(string $token, array $resources, \DateTime $st return $result; } - private function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool + public function intervalFree(array $schedule, \DateTime $from, \DateTime $to): bool { foreach ($schedule as $scheduleEntry) { if (!($scheduleEntry['startTime'] > $to || $scheduleEntry['endTime'] < $from)) { diff --git a/tests/Service/InteractiveServiceTest.php b/tests/Service/InteractiveServiceTest.php index 5f5fee8b4..9ce070292 100644 --- a/tests/Service/InteractiveServiceTest.php +++ b/tests/Service/InteractiveServiceTest.php @@ -5,7 +5,8 @@ namespace App\Tests\Service; use App\Entity\Tenant\Slide; -use App\Exceptions\InteractiveSlideException; +use App\Exceptions\BadRequestException; +use App\Exceptions\NotAcceptableException; use App\InteractiveSlide\InstantBook; use App\InteractiveSlide\InteractionSlideRequest; use App\Repository\UserRepository; @@ -41,7 +42,7 @@ public function testParseRequestBody(): void { $interactiveService = $this->container->get(InteractiveSlideService::class); - $this->expectException(InteractiveSlideException::class); + $this->expectException(BadRequestException::class); $interactiveService->parseRequestBody([ 'test' => 'test', @@ -73,19 +74,19 @@ public function testPerformAction(): void 'data' => [], ]); - $this->expectException(InteractiveSlideException::class); - $this->expectExceptionMessage('Interactive slide not found'); + $this->expectException(NotAcceptableException::class); + $this->expectExceptionMessage('Interactive slide config not found'); $tenant = $user->getActiveTenant(); - $interactiveService->performAction($user, $slide, $interactionRequest); + $interactiveService->performAction($tenant, $slide, $interactionRequest); $interactiveService->saveConfiguration($tenant, InstantBook::class, []); - $this->expectException(InteractiveSlideException::class); + $this->expectException(NotAcceptableException::class); $this->expectExceptionMessage('Action not allowed'); - $interactiveService->performAction($user, $slide, $interactionRequest); + $interactiveService->performAction($tenant, $slide, $interactionRequest); } public function testGetConfigurables(): void From acc47c43dbd42b226253328fac71b5a12a9690e3 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:45:25 +0200 Subject: [PATCH 13/20] 4992: Removed status codes --- src/InteractiveSlide/InstantBook.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 1a3a91ce7..489575561 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -111,7 +111,7 @@ private function authenticate(array $configuration): array $password = $this->keyValueService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { - throw new NotAcceptableException('tenantId, clientId, username, password must all be set.', 400); + throw new NotAcceptableException('tenantId, clientId, username, password must all be set.'); } $url = self::LOGIN_ENDPOINT.$tenantId.self::OAUTH_PATH; @@ -303,13 +303,13 @@ function (CacheItemInterface $item) use ($now): \DateTime { ); if ($lastRequestDateTime->add(new \DateInterval(self::CACHE_LIFETIME_QUICK_BOOK_SPAM_PROTECT)) > $now) { - throw new TooManyRequestsException('Service unavailable', 503); + throw new TooManyRequestsException('Service unavailable'); } $interactiveSlideConfig = $this->interactiveService->getInteractiveSlideConfig($tenant, $interactionRequest->implementationClass); if (null === $interactiveSlideConfig) { - throw new NotAcceptableException('InteractiveSlideConfig not found', 400); + throw new NotAcceptableException('InteractiveSlideConfig not found'); } // Optional limiting of available resources. @@ -469,7 +469,7 @@ private function getValueFromInterval(string $key, InteractionSlideRequest $inte $value = $interval[$key] ?? null; if (null === $value) { - throw new BadRequestException("interval.'.$key.' not set.", 400); + throw new BadRequestException("interval.'.$key.' not set."); } return $value; @@ -514,13 +514,13 @@ private function getAllowedResources(InteractiveSlideConfig $interactive): array $key = $configuration['resourceEndpoint'] ?? null; if (null === $key) { - throw new NotAcceptableException('resourceEndpoint not set', 400); + throw new NotAcceptableException('resourceEndpoint not set'); } $resourceEndpoint = $this->keyValueService->getValue($key); if (null === $resourceEndpoint) { - throw new NotAcceptableException('resourceEndpoint value not set', 400); + throw new NotAcceptableException('resourceEndpoint value not set'); } return $this->interactiveSlideCache->get(self::CACHE_ALLOWED_RESOURCES_PREFIX.$interactive->getId(), function (CacheItemInterface $item) use ($resourceEndpoint) { From d07526c316907e6f4bd6b5ce93f36471e1595ffc Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:33:06 +0200 Subject: [PATCH 14/20] 4992: Fixed table name. Renamed index --- migrations/Version20250828084617.php | 28 ++++++++++++++++++++ src/Entity/Tenant/InteractiveSlideConfig.php | 1 - 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20250828084617.php diff --git a/migrations/Version20250828084617.php b/migrations/Version20250828084617.php new file mode 100644 index 000000000..6c526f84a --- /dev/null +++ b/migrations/Version20250828084617.php @@ -0,0 +1,28 @@ +addSql('RENAME TABLE interactive_slide TO interactive_slide_config;'); + $this->addSql('ALTER TABLE interactive_slide_config RENAME INDEX idx_138e544d9033212a TO IDX_D30060259033212A'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE interactive_slide_config RENAME INDEX idx_d30060259033212a TO IDX_138E544D9033212A'); + $this->addSql('RENAME TABLE interactive_slide_config TO interactive_slide;'); + } +} diff --git a/src/Entity/Tenant/InteractiveSlideConfig.php b/src/Entity/Tenant/InteractiveSlideConfig.php index ea7fa46ab..987455b69 100644 --- a/src/Entity/Tenant/InteractiveSlideConfig.php +++ b/src/Entity/Tenant/InteractiveSlideConfig.php @@ -9,7 +9,6 @@ use Symfony\Component\Serializer\Annotation\Ignore; #[ORM\Entity(repositoryClass: InteractiveSlideRepository::class)] -#[ORM\Table(name: 'interactive_slide')] class InteractiveSlideConfig extends AbstractTenantScopedEntity { #[Ignore] From e7513896a9d2b2c0206eab89e200a9f22f56ccad Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:54:59 +0200 Subject: [PATCH 15/20] 4565: Aligned with 2.5.2. Cleaned out themes and fixtures from public. Added file to copy files from fixtures/public/fixtures to public/fixtures --- .gitignore | 3 + CHANGELOG.md | 2 + Taskfile.yml | 7 + assets/shared/slide-utils/global-styles.css | 84 +++--- assets/shared/templates/calendar.json | 12 +- .../calendar-single-booking-helper.jsx | 14 + .../calendar/calendar-single-booking.jsx | 110 +++++--- .../templates/image-text/image-text.scss | 4 + assets/shared/templates/news-feed.jsx | 19 +- .../shared/templates/news-feed/news-feed.scss | 4 +- assets/template/fixtures/slide-fixtures.js | 47 ++-- {public => docs}/release-example.json | 0 {public => docs}/themes/EXAMPLE.css | 0 {public => docs}/themes/README.md | 2 +- docs/v2-changelogs/template.md | 16 ++ .../fixtures/template/images/author.jpg | Bin .../public}/fixtures/template/images/logo.png | Bin .../fixtures/template/images/mountain1.jpeg | Bin .../fixtures/template/images/mountain2.jpeg | Bin .../fixtures/template/images/mountain3.jpeg | Bin .../fixtures/template/images/mountain4.jpeg | Bin .../template/images/sunset-full-hd.jpg | Bin .../fixtures/template/images/vertical.jpg | Bin .../public}/fixtures/template/videos/test.mp4 | Bin .../fixtures/template/videos/video.mp4 | Bin .../template/images/dokk1-rss-template-bg.jpg | Bin 277389 -> 0 bytes .../template/images/dokk1-shapes-animated.svg | 71 ----- public/themes/aakb.css | 225 ---------------- public/themes/aarhus.css | 31 --- public/themes/bautavej.css | 42 --- public/themes/blixen.css | 61 ----- public/themes/dokk1.css | 255 ------------------ public/themes/infostander.css | 17 -- public/themes/mso.css | 28 -- 34 files changed, 207 insertions(+), 847 deletions(-) rename {public => docs}/release-example.json (100%) rename {public => docs}/themes/EXAMPLE.css (100%) rename {public => docs}/themes/README.md (93%) rename {public => fixtures/public}/fixtures/template/images/author.jpg (100%) rename {public => fixtures/public}/fixtures/template/images/logo.png (100%) rename {public => fixtures/public}/fixtures/template/images/mountain1.jpeg (100%) rename {public => fixtures/public}/fixtures/template/images/mountain2.jpeg (100%) rename {public => fixtures/public}/fixtures/template/images/mountain3.jpeg (100%) rename {public => fixtures/public}/fixtures/template/images/mountain4.jpeg (100%) rename {public => fixtures/public}/fixtures/template/images/sunset-full-hd.jpg (100%) rename {public => fixtures/public}/fixtures/template/images/vertical.jpg (100%) rename {public => fixtures/public}/fixtures/template/videos/test.mp4 (100%) rename {public => fixtures/public}/fixtures/template/videos/video.mp4 (100%) delete mode 100644 public/fixtures/template/images/dokk1-rss-template-bg.jpg delete mode 100644 public/fixtures/template/images/dokk1-shapes-animated.svg delete mode 100644 public/themes/aakb.css delete mode 100644 public/themes/aarhus.css delete mode 100644 public/themes/bautavej.css delete mode 100644 public/themes/blixen.css delete mode 100644 public/themes/dokk1.css delete mode 100644 public/themes/infostander.css delete mode 100644 public/themes/mso.css diff --git a/.gitignore b/.gitignore index 21674ccc6..5092954fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Ignore custom templates folder. /assets/shared/custom-templates/* +# Ignore the public/fixtures folder. +/public/fixtures + # Ignore release json file. /public/release.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5ebe481..60bd8aba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to this project will be documented in this file. * Added update command. * Added (Client) online-check to public. * Updated developer documentation. +* Aligned with v. 2.5.2. +* Removed themes. ### NB! Prior to 3.x the project was split into separate repositories diff --git a/Taskfile.yml b/Taskfile.yml index 1289339aa..011281bc9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -30,6 +30,7 @@ tasks: - task compose-up - task composer-install - task db:migrate --yes + - task fixtures:copy-assets - task site-open silent: true @@ -44,6 +45,7 @@ tasks: - task composer-install - task db:migrate --yes - task fixtures:load --yes + - task fixtures:copy-assets - task site-open silent: true @@ -221,3 +223,8 @@ tasks: desc: "Migrate to latest database schema and update installed templates" cmds: - task compose -- exec phpfpm bin/console app:update --no-interaction + + fixtures:copy-assets: + desc: "Copy the folder from fixtures/public/fixtures to public/fixtures. Rerun if fixtures are changed." + cmds: + - task compose -- exec phpfpm cp -r fixtures/public/fixtures public/fixtures diff --git a/assets/shared/slide-utils/global-styles.css b/assets/shared/slide-utils/global-styles.css index caf55753f..94f2587d3 100644 --- a/assets/shared/slide-utils/global-styles.css +++ b/assets/shared/slide-utils/global-styles.css @@ -47,63 +47,39 @@ --text-light: var(--color-light); --text-dark: var(--color-dark); - --color-red-oklch-ch: 0.25 29; - --color-red-oklch-l: 50%; - --color-red-oklch-c: 0.25; - --color-red-oklch-h: 29; - --color-red-50: oklch( - 95% calc(var(--color-red-oklch-c) - 0.2) var(--color-red-oklch-h) + --color-red-hsl-h: 0deg; + --color-red-hsl-s: 84%; + --color-red-hsl-l: 50%; + --color-red-50: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 95%); + --color-red-100: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 90%); + --color-red-200: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 85%); + --color-red-300: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 80%); + --color-red-400: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 70%); + --color-red-500: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 60%); + --color-red-600: hsl( + var(--color-red-hsl-h) var(--color-red-hsl-s) var(--color-red-hsl-l) ); - --color-red-100: oklch(90% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-200: oklch(85% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-300: oklch(80% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-400: oklch(70% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-500: oklch(60% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-600: oklch( - var(--color-red-oklch-l) var(--color-red-oklch-c) var(--color-red-oklch-h) - ); - --color-red-700: oklch(40% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-800: oklch(30% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-900: oklch(20% var(--color-red-oklch-c) var(--color-red-oklch-h)); - --color-red-950: oklch(15% var(--color-red-oklch-c) var(--color-red-oklch-h)); + --color-red-700: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 40%); + --color-red-800: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 30%); + --color-red-900: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 20%); + --color-red-950: hsl(var(--color-red-hsl-h) var(--color-red-hsl-s) 15%); - --color-green-oklch-l: 50%; - --color-green-oklch-c: 0.17; - --color-green-oklch-h: 142; - --color-green-50: oklch( - 95% calc(var(--color-green-oklch-c) - 0.15) var(--color-green-oklch-h) - ); - --color-green-100: oklch( - 90% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-200: oklch( - 85% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-300: oklch( - 80% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-400: oklch( - 70% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-500: oklch( - 60% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-600: oklch( - var(--color-green-oklch-l) var(--color-green-oklch-c) - var(--color-green-oklch-h) - ); - --color-green-700: oklch( - 40% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-800: oklch( - 30% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-900: oklch( - 20% var(--color-green-oklch-c) var(--color-green-oklch-h) - ); - --color-green-950: oklch( - 15% var(--color-green-oklch-c) var(--color-green-oklch-h) + --color-green-hsl-h: 142deg; + --color-green-hsl-s: 76%; + --color-green-hsl-l: 50%; + --color-green-50: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 95%); + --color-green-100: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 90%); + --color-green-200: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 85%); + --color-green-300: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 80%); + --color-green-400: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 70%); + --color-green-500: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 60%); + --color-green-600: hsl( + var(--color-green-hsl-h) var(--color-green-hsl-s) var(--color-green-hsl-l) ); + --color-green-700: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 40%); + --color-green-800: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 30%); + --color-green-900: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 20%); + --color-green-950: hsl(var(--color-green-hsl-h) var(--color-green-hsl-s) 15%); /* * Fonts diff --git a/assets/shared/templates/calendar.json b/assets/shared/templates/calendar.json index 4e3878f9e..ebee04c1d 100644 --- a/assets/shared/templates/calendar.json +++ b/assets/shared/templates/calendar.json @@ -132,7 +132,7 @@ "name": "resourceAvailableText", "type": "text", "label": "Tekst når resursen er ledig", - "helpText": "Her kan du skrive tekst, som vises når resursen er ledig.", + "helpText": "Her kan du skrive tekst, som vises når resursen er ledig. Dette gælder kun for \"Enkelt lokale\" layoutet", "formGroupClasses": "col-md-6" }, { @@ -163,7 +163,7 @@ { "key": "calendar-form-has-date-and-time", "input": "checkbox", - "label": "Vis dato og tidspunkt", + "label": "Vis dato og tidspunkt. Gælder kun for \"Flere resurser\" layoutet.", "name": "hasDateAndTime", "formGroupClasses": "col-md-6 mb-3" }, @@ -214,6 +214,14 @@ "name": "headerOrder", "formGroupClasses": "col-md-6 mb-3", "helpText": "Dette er kun relevant hvis \"Flere resurser\" er valgt under \"layout\". Standard er \"Hvornår, hvad, hvor.\"" + }, + { + "key": "calendar-form-enable-instant-booking", + "input": "checkbox", + "label": "Aktivér straksbooking", + "helpText": "Aktivér mulighed for straksbooking. Dette kræver at resursen er opsat og godkendt af systemadministrator.", + "name": "instantBookingEnabled", + "formGroupClasses": "mb-3" } ] } diff --git a/assets/shared/templates/calendar/calendar-single-booking-helper.jsx b/assets/shared/templates/calendar/calendar-single-booking-helper.jsx index a1e4acb98..eade82949 100644 --- a/assets/shared/templates/calendar/calendar-single-booking-helper.jsx +++ b/assets/shared/templates/calendar/calendar-single-booking-helper.jsx @@ -24,6 +24,9 @@ const Wrapper = styled.div` const Header = styled.div` /* Header styling */ display: flex; + @media (max-width: 800px) { + flex-wrap: wrap; + } `; const RoomInfo = styled.div` @@ -31,6 +34,10 @@ const RoomInfo = styled.div` padding: calc(var(--padding-size-base) * 2); flex-grow: 2; color: var(--text-light); + @media (max-width: 800px) { + padding-top: var(--padding-size-base); + padding-bottom: var(--padding-size-base); + } `; const Title = styled.div` @@ -45,6 +52,7 @@ const SubTitle = styled.div` const Status = styled.div` /* Status styling */ padding: var(--padding-size-base); + padding-left: calc(var(--padding-size-base) * 2); padding-right: calc(var(--padding-size-base) * 3); display: flex; column-gap: var(--spacer); @@ -70,6 +78,12 @@ const DateTime = styled.div` text-align: right; padding: var(--padding-size-base); color: var(--text-dark); + align-content: center; + @media (max-width: 800px) { + flex-basis: 100%; + padding-left: calc(var(--padding-size-base) * 2); + text-align: left; + } `; const Date = styled.div` diff --git a/assets/shared/templates/calendar/calendar-single-booking.jsx b/assets/shared/templates/calendar/calendar-single-booking.jsx index 34d345ca1..10d507e80 100644 --- a/assets/shared/templates/calendar/calendar-single-booking.jsx +++ b/assets/shared/templates/calendar/calendar-single-booking.jsx @@ -46,15 +46,20 @@ import { * @returns {React.JSX.Element} - The component. */ function CalendarSingleBooking({ - content, - calendarEvents, - templateClasses = [], - templateRootStyle = {}, - getTitle, - slide, - run, -}) { - const { title = "", subTitle = null, mediaContain } = content; + content, + calendarEvents, + templateClasses = [], + templateRootStyle = {}, + getTitle, + slide, + run, + }) { + const { + title = "", + subTitle = null, + mediaContain, + instantBookingEnabled = false, + } = content; // Get values from client localstorage. const token = localStorage.getItem("apiToken"); @@ -70,6 +75,10 @@ function CalendarSingleBooking({ const [bookingError, setBookingError] = useState(false); const fetchBookingIntervals = () => { + if (!instantBookingEnabled) { + return; + } + if (!apiUrl || !slide || !token || !tenantKey) { setFetchingIntervals(false); return; @@ -109,7 +118,7 @@ function CalendarSingleBooking({ to: option.to, durationMinutes: option.durationMinutes, }; - }), + }) ); }) .finally(() => { @@ -147,15 +156,20 @@ function CalendarSingleBooking({ const instantBooking = getInstantBookingFromLocalStorage(slide["@id"]); + let newBookingResult = null; + // Clean out old instantBookings. - if (instantBooking) { - if (dayjs(instantBooking.interval.to) < dayjs()) { - setInstantBookingFromLocalStorage(slide["@id"], null); - setBookingResult(null); + if (instantBooking !== null) { + const intervalFrom = instantBooking?.interval?.to; + + if (intervalFrom !== null && dayjs(intervalFrom) > dayjs()) { + newBookingResult = instantBooking; } else { - setBookingResult(instantBooking); + setInstantBookingFromLocalStorage(slide["@id"], null); } } + + setBookingResult(newBookingResult); }; const clickInterval = (interval) => { @@ -163,6 +177,10 @@ function CalendarSingleBooking({ return; } + if (!instantBookingEnabled) { + return; + } + setProcessingBooking(true); fetch(`${apiUrl}${slide["@id"]}/action`, { @@ -209,19 +227,24 @@ function CalendarSingleBooking({ }, []); useEffect(() => { - fetchBookingIntervals(); + if (instantBookingEnabled) { + fetchBookingIntervals(); + } }, [run]); const currentEvents = calendarEvents.filter( (cal) => - cal.startTime <= currentTime.unix() && cal.endTime >= currentTime.unix(), + cal.startTime <= currentTime.unix() && cal.endTime >= currentTime.unix() ); const futureEvents = calendarEvents.filter( - (el) => !currentEvents.includes(el), + (el) => + !currentEvents.includes(el) && + el.endTime > dayjs().unix() && + el.endTime <= dayjs().endOf("day").unix() ); - const roomInUse = currentEvents.length > 0; + const roomInUse = bookingResult !== null || currentEvents.length > 0; const roomAvailableForInstantBooking = !roomInUse && fetchingIntervals ? null : bookableIntervals?.length > 0; @@ -237,21 +260,22 @@ function CalendarSingleBooking({ return (
- + {subTitle && {subTitle}} {title} - + {roomInUse ? ( @@ -268,6 +292,7 @@ function CalendarSingleBooking({
- {roomInUse && - currentEvents.map((event) => ( - - - {renderTimeOfDayFromUnixTimestamp(event.startTime)} - {" - "} - {renderTimeOfDayFromUnixTimestamp(event.endTime)} - -

{getTitle(event.title)}

-
- ))} - {!roomInUse && ( + {roomInUse && ( + <> + {bookingResult && ( + +

+ {" "} + {dayjs(bookingResult.interval.to) + .locale(localeDa) + .format("HH:mm")} +

+
+ )} + {!bookingResult && + currentEvents.map((event) => ( + + + {renderTimeOfDayFromUnixTimestamp(event.startTime)} + {" - "} + {renderTimeOfDayFromUnixTimestamp(event.endTime)} + +

{getTitle(event.title)}

+
+ ))} + + )} + {!roomInUse && instantBookingEnabled && ( <> {!processingBooking && !bookingResult && !bookingError && ( diff --git a/assets/shared/templates/image-text/image-text.scss b/assets/shared/templates/image-text/image-text.scss index f734ad437..2845f4d2e 100644 --- a/assets/shared/templates/image-text/image-text.scss +++ b/assets/shared/templates/image-text/image-text.scss @@ -40,6 +40,10 @@ .text { margin-top: var(--spacer); + + p { + margin-bottom: 1em; + } } &.full-screen { diff --git a/assets/shared/templates/news-feed.jsx b/assets/shared/templates/news-feed.jsx index 8a147d1e1..50b3ff4d0 100644 --- a/assets/shared/templates/news-feed.jsx +++ b/assets/shared/templates/news-feed.jsx @@ -31,6 +31,8 @@ function renderSlide(slide, run, slideDone) { /> ); } + + /** * News feed slide. * @@ -49,6 +51,7 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { const [currentPost, setCurrentPost] = useState(null); const [posts, setPosts] = useState([]); const [qr, setQr] = useState(null); + const transitionRef = useRef(null); const timerRef = useRef(); @@ -82,6 +85,7 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { setQr(null); } else { QRCode.toDataURL(currentPost.link, { + margin: 0, color: { dark: "#000000", light: "#ffffff00", @@ -106,8 +110,13 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { }, [posts]); useEffect(() => { - if (feedData && Object.hasOwnProperty.call(feedData, "entries")) { + if (feedData?.entries?.length > 0) { setPosts(feedData.entries); + } else if (!transitionRef.current) { + // If no content, wait 5 seconds and continue to next slide. + transitionRef.current = setTimeout(() => { + slideDone(slide); + }, 5000); } }, [feedData]); @@ -119,6 +128,14 @@ function NewsFeed({ slide, content, run, slideDone, executionId }) { } }, [run]); + useEffect(() => { + return () => { + if (transitionRef.current) { + clearInterval(transitionRef.current); + } + }; + }, []); + const getImageUrl = (post) => { let imageUrl = fallbackImageUrl ?? null; diff --git a/assets/shared/templates/news-feed/news-feed.scss b/assets/shared/templates/news-feed/news-feed.scss index 565c92e00..405647c74 100644 --- a/assets/shared/templates/news-feed/news-feed.scss +++ b/assets/shared/templates/news-feed/news-feed.scss @@ -86,8 +86,8 @@ justify-content: end; .qr { - width: 20%; - margin-bottom: 2%; + width: 15%; + margin-bottom: 5%; } .read-more { diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js index ded7d7af7..d6d2d723d 100644 --- a/assets/template/fixtures/slide-fixtures.js +++ b/assets/template/fixtures/slide-fixtures.js @@ -6,7 +6,7 @@ const slideFixtures = [ templateData: { id: "01FP2SME0ENTXWF362XHM6Z1B4", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -33,7 +33,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feedData: [ { id: "uniqueEventMinusTwo", @@ -294,7 +294,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/bautavej.css", + themeFile: null, feedData: [ { id: "uniqueEventMinusTwo", @@ -458,7 +458,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feedData: [ { id: "uniqueEvent0", @@ -602,7 +602,7 @@ const slideFixtures = [ templateData: { id: "01FRJPF4XATRN8PBZ35XN84PS6", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feed: { resources: ["test-lokale@display-templates.local.itkdev.dk"], }, @@ -652,6 +652,7 @@ const slideFixtures = [ darkModeEnabled: false, content: { duration: 60000, + instantBookingEnabled: true, layout: "singleBooking", title: "M2.3", subTitle: "Mødelokale", @@ -667,7 +668,7 @@ const slideFixtures = [ templateData: { id: "01FPZ19YEHX7MQ5Q6ZS0WK0VEA", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -751,7 +752,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -779,7 +780,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -822,7 +823,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -860,7 +861,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNGFN0BZQH03KCBXHKYHG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -968,7 +969,7 @@ const slideFixtures = [ templateData: { id: "01JEWPAFF93YSF418TH72W1SBA", }, - themeFile: "/themes/aarhus.css", + themeFile: null, // Disable dark mode for slide. darkModeEnabled: false, feed: { @@ -1049,7 +1050,7 @@ const slideFixtures = [ mediaData: { "/v1/media/00000000000000000000000001": { assets: { - uri: "/fixtures/template/images/dokk1-rss-template-bg.jpg", + uri: "/fixtures/template/images/mountain1.jpeg", }, }, }, @@ -1064,7 +1065,7 @@ const slideFixtures = [ templateData: { id: "01FWJZQ25A1868V63CWYYHQFKQ", }, - themeFile: "/themes/aakb.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -1126,7 +1127,7 @@ const slideFixtures = [ templateData: { id: "01FWJZQ25A1868V63CWYYHQFKQ", }, - themeFile: "/themes/aarhus.css", + themeFile: null, feed: { configuration: { overrideTitle: null, @@ -1192,7 +1193,7 @@ const slideFixtures = [ templateData: { id: "01FWJZQ25A1868V63CWYYHQFKQ", }, - themeFile: "/themes/aakb.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -1232,7 +1233,7 @@ const slideFixtures = [ templateData: { id: "01FQC300GGWCA7A8H0SXY6P9FG", }, - themeFile: "/themes/dokk1.css", + themeFile: null, feed: { configuration: { numberOfEntries: 5, @@ -1280,7 +1281,7 @@ const slideFixtures = [ mediaData: { "/v1/media/00000000000000000000000001": { assets: { - uri: "/fixtures/template/images/dokk1-shapes-animated.svg", + uri: "/fixtures/template/mountain1.jpeg", }, }, }, @@ -1358,7 +1359,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNSC9VXD10ZKXQR819NS9", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -1409,7 +1410,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNSC9VXD10ZKXQR819NS9", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -1460,7 +1461,7 @@ const slideFixtures = [ templateData: { id: "01FP2SNSC9VXD10ZKXQR819NS9", }, - themeFile: "/themes/dokk1.css", + themeFile: null, theme: { logo: { assets: { @@ -1513,7 +1514,7 @@ const slideFixtures = [ templateData: { id: "01FQBJFKM0YFX1VW5K94VBSNCP", }, - themeFile: "/themes/aarhus.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { @@ -1563,7 +1564,7 @@ const slideFixtures = [ templateData: { id: "01FQBJFKM0YFX1VW5K94VBSNCP", }, - themeFile: "/themes/aarhus.css", + themeFile: null, content: { duration: 5000, title: "Overskrift2", @@ -1704,7 +1705,7 @@ const slideFixtures = [ templateData: { id: "01FQBJFKM0YFX1VW5K94VBSNCC", }, - themeFile: "/themes/dokk1.css", + themeFile: null, mediaData: { "/v1/media/00000000000000000000000001": { assets: { diff --git a/public/release-example.json b/docs/release-example.json similarity index 100% rename from public/release-example.json rename to docs/release-example.json diff --git a/public/themes/EXAMPLE.css b/docs/themes/EXAMPLE.css similarity index 100% rename from public/themes/EXAMPLE.css rename to docs/themes/EXAMPLE.css diff --git a/public/themes/README.md b/docs/themes/README.md similarity index 93% rename from public/themes/README.md rename to docs/themes/README.md index b8eb08a81..897b28853 100644 --- a/public/themes/README.md +++ b/docs/themes/README.md @@ -8,7 +8,7 @@ For the styles to have effect you will need to append `#SLIDE_ID` to all styling An example file can be found in this directory. -Below is a example of variables on the slide container. +Below is an example of variables on the slide container. ```css diff --git a/docs/v2-changelogs/template.md b/docs/v2-changelogs/template.md index 4a484e69c..10084ceff 100644 --- a/docs/v2-changelogs/template.md +++ b/docs/v2-changelogs/template.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. ## Unreleased +## [2.5.2] - 2025-09-04 + +- [#189](https://github.com/os2display/display-templates/pull/189) + - Set margin-bottom of p element of rich text in image-text. +- [#188](https://github.com/os2display/display-templates/pull/188) + - Fixed issues with calendar single booking layout. + - Added extra description for resourceAvailableText and hasDateAndTime fields. +- [#185](https://github.com/os2display/display-templates/pull/185) + - Single Clanedar Booking: Fix color issues on older browsers. Add simple vertical view for portrait oriented devices. +- [#184](https://github.com/os2display/display-templates/pull/184) + - Update Dokk1 and Aakb themes. Add news feed. Adapt new identity for Aakb. + +## [2.5.1] - 2025-06-23 + +- [#178](https://github.com/os2display/display-templates/pull/178) + - Fixed news-feed template blocks slide transitions. - [#177](https://github.com/os2display/display-templates/pull/177) - Set options.disableLivePreview for iframe templates diff --git a/public/fixtures/template/images/author.jpg b/fixtures/public/fixtures/template/images/author.jpg similarity index 100% rename from public/fixtures/template/images/author.jpg rename to fixtures/public/fixtures/template/images/author.jpg diff --git a/public/fixtures/template/images/logo.png b/fixtures/public/fixtures/template/images/logo.png similarity index 100% rename from public/fixtures/template/images/logo.png rename to fixtures/public/fixtures/template/images/logo.png diff --git a/public/fixtures/template/images/mountain1.jpeg b/fixtures/public/fixtures/template/images/mountain1.jpeg similarity index 100% rename from public/fixtures/template/images/mountain1.jpeg rename to fixtures/public/fixtures/template/images/mountain1.jpeg diff --git a/public/fixtures/template/images/mountain2.jpeg b/fixtures/public/fixtures/template/images/mountain2.jpeg similarity index 100% rename from public/fixtures/template/images/mountain2.jpeg rename to fixtures/public/fixtures/template/images/mountain2.jpeg diff --git a/public/fixtures/template/images/mountain3.jpeg b/fixtures/public/fixtures/template/images/mountain3.jpeg similarity index 100% rename from public/fixtures/template/images/mountain3.jpeg rename to fixtures/public/fixtures/template/images/mountain3.jpeg diff --git a/public/fixtures/template/images/mountain4.jpeg b/fixtures/public/fixtures/template/images/mountain4.jpeg similarity index 100% rename from public/fixtures/template/images/mountain4.jpeg rename to fixtures/public/fixtures/template/images/mountain4.jpeg diff --git a/public/fixtures/template/images/sunset-full-hd.jpg b/fixtures/public/fixtures/template/images/sunset-full-hd.jpg similarity index 100% rename from public/fixtures/template/images/sunset-full-hd.jpg rename to fixtures/public/fixtures/template/images/sunset-full-hd.jpg diff --git a/public/fixtures/template/images/vertical.jpg b/fixtures/public/fixtures/template/images/vertical.jpg similarity index 100% rename from public/fixtures/template/images/vertical.jpg rename to fixtures/public/fixtures/template/images/vertical.jpg diff --git a/public/fixtures/template/videos/test.mp4 b/fixtures/public/fixtures/template/videos/test.mp4 similarity index 100% rename from public/fixtures/template/videos/test.mp4 rename to fixtures/public/fixtures/template/videos/test.mp4 diff --git a/public/fixtures/template/videos/video.mp4 b/fixtures/public/fixtures/template/videos/video.mp4 similarity index 100% rename from public/fixtures/template/videos/video.mp4 rename to fixtures/public/fixtures/template/videos/video.mp4 diff --git a/public/fixtures/template/images/dokk1-rss-template-bg.jpg b/public/fixtures/template/images/dokk1-rss-template-bg.jpg deleted file mode 100644 index 5b6d725b7df62172940032daa66ca2925e3500fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 277389 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 diff --git a/public/fixtures/template/images/dokk1-shapes-animated.svg b/public/fixtures/template/images/dokk1-shapes-animated.svg deleted file mode 100644 index 797ce6c23..000000000 --- a/public/fixtures/template/images/dokk1-shapes-animated.svg +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - diff --git a/public/themes/aakb.css b/public/themes/aakb.css deleted file mode 100644 index 24e2feb86..000000000 --- a/public/themes/aakb.css +++ /dev/null @@ -1,225 +0,0 @@ -/* -* Aarhus Kommunes Biblioteker theme css -*/ - -/* Import FaktPro font */ -@font-face { - font-family: "FaktPro-Normal"; - src: url("https://db.onlinewebfonts.com/t/fdd40b399610a1e015242521427561b1.woff") - format("woff"); - font-display: auto; - font-weight: 400; - font-style: normal; -} - -@font-face { - font-family: "FaktPro-SemiBold"; - src: url("https://db.onlinewebfonts.com/t/2eed23505916e842a2d6b7eeb0e4def0.woff") - format("woff"); - font-weight: 400; - font-style: normal; -} - - -#SLIDE_ID { - /* Default variables */ - /* --bg-light: #f6f6f6; */ - --bg-light: #fff; - --text-dark: #000; - --color-primary: #f26306; - --color-secondary: #ef0043; - --color-success: #008850; - --color-info: #008488; - --color-warning: #fee13d; - --color-danger: #d32f2e; - - --color-blue: var(--color-primary); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --font-family-base: "FaktPro-Normal", sans-serif; - --font-family-bold: "FaktPro-SemiBold", sans-serif; - --font-weight-base: 400; - --font-weight-bold: 400; - - /* Darkmode overrides */ - --bg-dark: #333333; - --text-light: #ffffff; - - font-family: var(--font-family-base); -} - -/* -* -* Customizations for poster template -* -*/ - -#SLIDE_ID .template-poster .header-area { - padding: 10%; -} - -#SLIDE_ID .template-poster .info-area { - padding: 10%; - font-size: calc(var(--font-size-base) * 1.25); - font-weight: 300; -} - -#SLIDE_ID .template-poster .logo-area { - padding: 3% 10% 0 10%; - background-color: var(--bg-light); -} - -#SLIDE_ID .template-poster img { - margin-right: 0; -} - -#SLIDE_ID .template-poster h1 { - font-family: var(--font-family-base); - font-weight: var(--font-weight-base); -} - -#SLIDE_ID .template-poster .lead { - font-family: var(--font-family-bold); - font-weight: var(--font-weight-bold); - font-size: calc(var(--font-size-base) * 1.25); -} - -#SLIDE_ID .template-poster .info-area .date { - margin-bottom: 2%; -} - -#SLIDE_ID .template-poster .look-like-link { - color: var(--color-primary); -} - -/* -* -* Customize Book review template styling -* -*/ - -#SLIDE_ID .template-book-review { - --text-color: var(--color-grey-700); -} - -#SLIDE_ID .template-book-review .author { - --text-color: var(--color-grey-500); -} - -/* -* -* Customize RSS template styling -* -*/ - -#SLIDE_ID .template-rss { - --text-color: var(--text-light, hsl(0deg, 0%, 100%)); - padding: calc(var(--spacer) * 4); - gap: calc(var(--spacer) * 6); - background-color: var(--color-primary); - color: var(--text-color); -} - -.color-scheme-dark #SLIDE_ID .template-rss { - --text-color: var(--text-dark, hsl(0deg, 0%, 0%)); -} - -#SLIDE_ID .template-rss .feed-info { - gap: calc(var(--spacer) * 2); -} - -#SLIDE_ID .template-rss .feed-info--date { - border-right: 3px solid var(--color-white); - padding-right: calc(var(--spacer) * 2); - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .feed-info--title, -#SLIDE_ID .template-rss .feed-info--date, -#SLIDE_ID .template-rss .feed-info--progress { - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .content { - gap: calc(var(--spacer) * 2); -} - -#SLIDE_ID .template-rss .title { - font-size: calc(var(--font-size-base) * 5); - font-weight: var(--font-weight-bold); - line-height: 1.2; -} - -#SLIDE_ID .template-rss .description { - font-size: calc(var(--font-size-base) * 3); - line-height: 1.3; -} - -/* -* -* Customize Image text template -* -*/ - -#SLIDE_ID .template-image-text .box { - background-color: var(--bg-dark); - color: var(--text-light); -} - -#SLIDE_ID .template-image-text.reversed { - color: var(--text-light); - text-shadow: var(--shadow-text-m); -} -#SLIDE_ID .template-image-text.reversed .box { - background-color: transparent; - box-shadow: none; -} - -/* -* -* Customize Instagram template styling -* -*/ -#SLIDE_ID .template-instagram-feed { - --h1-font-size: calc(var(--font-size-base) * 3.5); - --h4-font-size: calc(var(--font-size-base) * 1.75); - --font-size-xl: calc(var(--font-size-base) * 2); - - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section { - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section .description .text { - display: -webkit-box; - max-height: 74%; - line-clamp: 8; - -webkit-line-clamp: 8; - -webkit-box-orient: vertical; - overflow: hidden; -} - -#SLIDE_ID .template-instagram-feed .author-section .date { - color: var(--color-primary); - display: block; -} - -#SLIDE_ID .template-instagram-feed .shape { - display: none; -} - -#SLIDE_ID .template-instagram-feed .brand { - bottom: 0; - padding-top: calc(var(--spacer) * 2); - padding-bottom: calc(var(--spacer) * 2); - color: var(--color-primary); - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed.landscape .brand { - width: var(--percentage-narrow); -} \ No newline at end of file diff --git a/public/themes/aarhus.css b/public/themes/aarhus.css deleted file mode 100644 index e8d62f3e2..000000000 --- a/public/themes/aarhus.css +++ /dev/null @@ -1,31 +0,0 @@ -/* -* Aarhus theme css -*/ - -#SLIDE_ID { - /* Default variables */ - /* --bg-light: #f6f6f6; */ - --bg-light: #f6f6f6; - --text-dark: #333333; - --color-primary: #3761d9; - --color-secondary: #ef0043; - --color-success: #008850; - --color-info: #008488; - --color-warning: #fee13d; - --color-danger: #d32f2e; - - --color-blue: var(--color-primary); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --font-family-base: Arial, "sans-serif"; - --font-weight-light: 300; - --font-weight-bold: 600; - - /* Darkmode overrides */ - --bg-dark: #333333; - --text-light: #ffffff; - - font-family: "Arial", sans-serif; -} diff --git a/public/themes/bautavej.css b/public/themes/bautavej.css deleted file mode 100644 index 589e35fb8..000000000 --- a/public/themes/bautavej.css +++ /dev/null @@ -1,42 +0,0 @@ -/* -* Bautavej theme file -* -* #SLIDE_ID should always encapsulate all your theme styling -* #SLIDE_ID will be replaced at runtime with the given slide execution id to make sure the theme styling -* only applies to the given slide. -*/ - -#SLIDE_ID { - --color-primary: hsla(125, 49%, 42%, 1); - --color-grey-100: hsl(0, 100%, 900%); -} - -/* -* Styling for Calendar multiple -*/ -#SLIDE_ID .calendar-multiple { - /* Remove borders */ - --border: 0px; - /* Reduce space between items */ - --content-item-padding: calc(var(--padding) * 0.5); - padding: 0; - color: black; - background-color: var(--color-grey-100); -} - -#SLIDE_ID .calendar-multiple .header { - color: white; - background-color: var(--color-primary); - align-items: center; -} - -#SLIDE_ID .calendar-multiple .header-title { - font-size: var(--h3-font-size); -} - -#SLIDE_ID .calendar-multiple .content-item, -#SLIDE_ID .calendar-multiple .content-item-time, -#SLIDE_ID .calendar-multiple .content-item-title, -#SLIDE_ID .calendar-multiple .content-item-resource { - padding-bottom: var(--content-item-padding); -} diff --git a/public/themes/blixen.css b/public/themes/blixen.css deleted file mode 100644 index d2f125bd2..000000000 --- a/public/themes/blixen.css +++ /dev/null @@ -1,61 +0,0 @@ -/* -** Blixen theme css -*/ - -/* Import roboto slab font from google - Used with Blixen theme */ -/* NOTE: The fonts are inserted directly instead of through @import, since they failed to load with this @import: */ -/* @import url("https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@300;700&display=swap"); */ - -/* latin */ -@font-face { - font-family: 'Roboto Slab'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: url(https://fonts.gstatic.com/s/robotoslab/v24/BngMUXZYTXPIvIBgJJSb6ufN5qWr4xCC.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* latin */ -@font-face { - font-family: 'Roboto Slab'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url(https://fonts.gstatic.com/s/robotoslab/v24/BngMUXZYTXPIvIBgJJSb6ufN5qWr4xCC.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -#SLIDE_ID { - /* Default variabels */ - --color-primary: #673ab7; - --font-family-base: "Roboto Slab", serif; - --background-color: var(--bg-dark); - --text-color: var(--color-light); - --padding-size-base: 60px; - --margin-size-base: 60px; - - font-family: var(--font-family-base); -} - -/* Customize calender single template styling */ -#SLIDE_ID .calendar-single { - --background-color: var(--color-primary); - --text-color: var(--color-light); - --font-size-base: 2rem; - text-align: right; -} - -#SLIDE_ID .calendar-single .title, -#SLIDE_ID .calendar-single .subtitle { - font-size: 4rem; - font-weight: 300; -} -#SLIDE_ID .calendar-single .subtitle { - font-weight: 700; - margin-bottom: 60px; -} - -#SLIDE_ID .calendar-single .content-item { - font-family: Arial, sans-serif; - border-left: 0; -} diff --git a/public/themes/dokk1.css b/public/themes/dokk1.css deleted file mode 100644 index 496ee5eb3..000000000 --- a/public/themes/dokk1.css +++ /dev/null @@ -1,255 +0,0 @@ -/* -** DOKK1 theme css -*/ - -/* Import Gibson font from typekit - Used with Dokk1 theme */ -@import url("https://p.typekit.net/p.css?s=1&k=ilx8ovv&ht=tk&f=24355.24356.43309.43310&a=3352895&app=typekit&e=css"); -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/6c50f4/00000000000000007735a544/30/l?subset_id=2&fvd=n6&v=3") - format("woff2"), - url("https://use.typekit.net/af/6c50f4/00000000000000007735a544/30/d?subset_id=2&fvd=n6&v=3") - format("woff"), - url("https://use.typekit.net/af/6c50f4/00000000000000007735a544/30/a?subset_id=2&fvd=n6&v=3") - format("opentype"); - font-display: auto; - font-style: normal; - font-weight: 600; -} -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/56af16/00000000000000007735a545/30/l?subset_id=2&fvd=i6&v=3") - format("woff2"), - url("https://use.typekit.net/af/56af16/00000000000000007735a545/30/d?subset_id=2&fvd=i6&v=3") - format("woff"), - url("https://use.typekit.net/af/56af16/00000000000000007735a545/30/a?subset_id=2&fvd=i6&v=3") - format("opentype"); - font-display: auto; - font-style: italic; - font-weight: 600; -} -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/37e7f5/00000000000000007735a548/30/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n3&v=3") - format("woff2"), - url("https://use.typekit.net/af/37e7f5/00000000000000007735a548/30/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n3&v=3") - format("woff"), - url("https://use.typekit.net/af/37e7f5/00000000000000007735a548/30/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n3&v=3") - format("opentype"); - font-display: auto; - font-style: normal; - font-weight: 300; -} -@font-face { - font-family: "canada-type-gibson"; - src: url("https://use.typekit.net/af/e171bf/00000000000000007735a549/30/l?subset_id=2&fvd=i3&v=3") - format("woff2"), - url("https://use.typekit.net/af/e171bf/00000000000000007735a549/30/d?subset_id=2&fvd=i3&v=3") - format("woff"), - url("https://use.typekit.net/af/e171bf/00000000000000007735a549/30/a?subset_id=2&fvd=i3&v=3") - format("opentype"); - font-display: auto; - font-style: italic; - font-weight: 300; -} - -/* Defaults */ -#SLIDE_ID { - --bg-light: #f1f0ef; - --text-dark: #222; - --color-primary: #003764; - --color-primary-opaque: hsla(207, 100%, 20%, 0.9); - --color-secondary: #887c76; - --color-success: #006c3b; - --color-info: #0dcaf0; - --color-warning: #ffb400; - --color-danger: #dc3545; - - --color-blue: var(--color-primary); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --color-grey-100: hsl(0deg 0% 95%); - --color-grey-200: hsl(0deg 0% 85%); - --color-grey-300: hsla(0, 0%, 69%, 1); - --color-grey-400: hsla(0, 0%, 52%, 1); - --color-grey-500: hsla(0, 0%, 35%, 1); - --color-grey-600: hsla(0, 0%, 20%, 1); - --color-grey-700: hsla(0, 0%, 18%, 1); - --color-grey-800: hsla(0, 0%, 13%, 1); - --color-grey-900: hsla(0, 0%, 9%, 1); - - --font-family-base: "canada-type-gibson", Gibson, Arial, "sans-serif"; - --font-weight-light: 300; - --font-weight-bold: 600; - - --bg-primary: var(--color-primary); - - --shadow-text-m: 0px 4px 16px hsla(0, 0%, 0%, 0.4); - - /* Darkmode overrides */ - --bg-dark: #212529; - --text-light: #ffffff; - - font-family: var(--font-family-base); -} - -/* Set seperator default color. */ -#SLIDE_ID .separator { - background-color: white; -} - -/* Customize calender single template styling */ -#SLIDE_ID .calendar-single { - --h1-font-size: 5rem; - --h4-font-size: 3rem; - --font-size-base: 2rem; - --padding-size-base: 4rem; - --background-color: var(--color-primary); - --text-color: var(--color-light); - --border: 3px solid var(--color-light); - background-image: none; -} - -/* -* -* Customize calender multiple template styling -* -*/ -#SLIDE_ID .calendar-multiple, -#SLIDE_ID .calendar-multiple-days { - /* Use same colors for both light and dark */ - --text-light: #ffffff; - --color-grey-100: var(--color-grey-900); - --color-grey-200: var(--color-grey-800); - --color-grey-300: var(--color-grey-700); - --color-grey-400: var(--color-grey-600); - --bg-dark: var(--color-grey-900); - --padding-size-base: 36px; - --background-color: var(--bg-dark); - --border: 1px solid var(--color-grey-900); - --color-primary: var(--color-yellow); - --text-color: var(--color-light); - background-image: none; -} - -#SLIDE_ID .calendar-multiple .header-title, -#SLIDE_ID .calendar-multiple-days .header-title { - color: var(--color-yellow); -} - -#SLIDE_ID .calendar-multiple .content-col, -#SLIDE_ID .calendar-multiple-days .content-col { - background-color: var(--color-grey-700); -} - -#SLIDE_ID .calendar-multiple .col-title, -#SLIDE_ID .calendar-multiple-days .col-title { - background-color: var(--color-grey-800); -} - -/* -* -* Customize Instagram template styling -* -*/ -#SLIDE_ID .template-instagram-feed { - --h1-font-size: calc(var(--font-size-base) * 3.5); - --h4-font-size: calc(var(--font-size-base) * 1.75); - --font-size-xl: calc(var(--font-size-base) * 2); - - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section { - background-color: var(--color-white); -} - -#SLIDE_ID .template-instagram-feed .author-section .date { - color: var(--color-grey-400); -} - -#SLIDE_ID .template-instagram-feed .shape svg { - fill: var(--color-grey-100); -} - -#SLIDE_ID .template-instagram-feed .brand { - color: var(--color-grey-500); -} - -/* -* -* Customize Book review template styling -* -*/ - -#SLIDE_ID .template-book-review { - --text-color: var(--color-grey-700); -} - -#SLIDE_ID .template-book-review .author { - --text-color: var(--color-grey-500); -} - -/* -* -* Customize RSS template styling -* -*/ - -#SLIDE_ID .template-rss { - --text-color: var(--text-light, hsl(0deg, 0%, 100%)); - padding: calc(var(--spacer) * 4); - gap: calc(var(--spacer) * 6); - background-color: var(--color-primary); - color: var(--text-color); -} - -.color-scheme-dark #SLIDE_ID .template-rss { - --text-color: var(--text-dark, hsl(0deg, 0%, 0%)); -} - -#SLIDE_ID .template-rss .feed-info--date { - border-right: 3px solid var(--color-white); - padding-right: calc(var(--spacer) * 2); - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .feed-info--title, -#SLIDE_ID .template-rss .feed-info--date, -#SLIDE_ID .template-rss .feed-info--progress { - font-size: calc(var(--font-size-base) * 2); -} - -#SLIDE_ID .template-rss .title { - font-size: calc(var(--font-size-base) * 5); - font-weight: var(--font-weight-bold); -} - -#SLIDE_ID .template-rss .description { - font-size: calc(var(--font-size-base) * 3); -} - -/* -* -* Customize Image text template -* -*/ - -#SLIDE_ID .template-image-text .box { - background-color: var(--color-primary-opaque); - color: var(--text-light); -} - -#SLIDE_ID .template-image-text.reversed .box { - background-color: transparent; -} -#SLIDE_ID .template-image-text.reversed { - color: var(--text-light); - text-shadow: var(--shadow-text-m); -} - -#SLIDE_ID .template-image-text.reversed h1 { - font-size: calc(var(--font-size-base) * 2); -} diff --git a/public/themes/infostander.css b/public/themes/infostander.css deleted file mode 100644 index 115e3fe1e..000000000 --- a/public/themes/infostander.css +++ /dev/null @@ -1,17 +0,0 @@ -#SLIDE_ID .template-image-text { - background-size: auto; /* We might wish to change this to `contain` instead of auto. Since larger images the will be forced inside the 544/416 container. */ - background-repeat: no-repeat; - background-position: top left; - background-color: transparent; - position: relative; - width: 416px; - height: 544px; -} - -#SLIDE_ID .template-image-text.preview { - background-size: cover; -} - -#SLIDE_ID .template-image-text .box { - display: none !important; -} diff --git a/public/themes/mso.css b/public/themes/mso.css deleted file mode 100644 index ee73e6b51..000000000 --- a/public/themes/mso.css +++ /dev/null @@ -1,28 +0,0 @@ -/* -** MSO theme css -*/ - -#SLIDE_ID { - /* Defaults */ - --bg-light: #f6f6f6; - --text-dark: #333333; - --color-primary: #008488; - --color-secondary: #ff5f31; - --color-success: #008850; - --color-info: #3761d9; - --color-warning: #fee13d; - --color-danger: #d32f2e; - - --color-blue: var(--color-info); - --color-red: var(--color-danger); - --color-yellow: var(--color-warning); - --color-green: var(--color-success); - - --font-family-base: Arial, "sans-serif"; - --font-weight-light: 300; - --font-weight-bold: 600; - - /* Darkmode overrides */ - --bg-dark: #333333; - --text-light: #ffffff; -} From 75327e5a931c891c9b0359cf6238ef010ceab18a Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:03:25 +0200 Subject: [PATCH 16/20] 4565: Fixed themes documentation --- README.md | 7 +++++++ docs/themes/{EXAMPLE.css => example.css} | 0 docs/themes/{README.md => themes.md} | 6 +++--- 3 files changed, 10 insertions(+), 3 deletions(-) rename docs/themes/{EXAMPLE.css => example.css} (100%) rename docs/themes/{README.md => themes.md} (76%) diff --git a/README.md b/README.md index 14719d356..b7d9fb752 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,13 @@ For example: booking system you can implement a "FeedSource" that fetches booking data from your source and normalizes it to match the calendar output model. +## Themes + +It is possible to create themes that can apply to select templates. See `/admin/themes` in the Admin. + +The theme css has to follow som rules. See [docs/themes/themes.md](docs/themes/themes.md) for instructions on writing +custom themes. + ## Custom Templates It is possible to include your own templates in your installation. diff --git a/docs/themes/EXAMPLE.css b/docs/themes/example.css similarity index 100% rename from docs/themes/EXAMPLE.css rename to docs/themes/example.css diff --git a/docs/themes/README.md b/docs/themes/themes.md similarity index 76% rename from docs/themes/README.md rename to docs/themes/themes.md index 897b28853..d4d83411d 100644 --- a/docs/themes/README.md +++ b/docs/themes/themes.md @@ -1,12 +1,12 @@ # Theme development -For the styles to have effect you will need to append `#SLIDE_ID` to all styling. +For the styles to have effect you will need to use the `#SLIDE_ID` to all styling. -`#SLIDE_ID` will be replaced with an actual id on the client to isolate the styling on the current slide. +`#SLIDE_ID` will be replaced with an actual id in the Client to isolate the styling on the current slide. ## Examples -An example file can be found in this directory. +An example file can be found in this directory: [example.css](example.css). Below is an example of variables on the slide container. From b8b6cf052b62b80fdfc93a5e56cce72debfd5016 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:15:30 +0200 Subject: [PATCH 17/20] 4565: Applied coding standards --- .../calendar/calendar-single-booking.jsx | 24 +++++++++---------- assets/shared/templates/news-feed.jsx | 1 - 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/assets/shared/templates/calendar/calendar-single-booking.jsx b/assets/shared/templates/calendar/calendar-single-booking.jsx index 10d507e80..5c484f754 100644 --- a/assets/shared/templates/calendar/calendar-single-booking.jsx +++ b/assets/shared/templates/calendar/calendar-single-booking.jsx @@ -46,14 +46,14 @@ import { * @returns {React.JSX.Element} - The component. */ function CalendarSingleBooking({ - content, - calendarEvents, - templateClasses = [], - templateRootStyle = {}, - getTitle, - slide, - run, - }) { + content, + calendarEvents, + templateClasses = [], + templateRootStyle = {}, + getTitle, + slide, + run, +}) { const { title = "", subTitle = null, @@ -118,7 +118,7 @@ function CalendarSingleBooking({ to: option.to, durationMinutes: option.durationMinutes, }; - }) + }), ); }) .finally(() => { @@ -234,14 +234,14 @@ function CalendarSingleBooking({ const currentEvents = calendarEvents.filter( (cal) => - cal.startTime <= currentTime.unix() && cal.endTime >= currentTime.unix() + cal.startTime <= currentTime.unix() && cal.endTime >= currentTime.unix(), ); const futureEvents = calendarEvents.filter( (el) => !currentEvents.includes(el) && el.endTime > dayjs().unix() && - el.endTime <= dayjs().endOf("day").unix() + el.endTime <= dayjs().endOf("day").unix(), ); const roomInUse = bookingResult !== null || currentEvents.length > 0; @@ -260,7 +260,7 @@ function CalendarSingleBooking({ return ( Date: Fri, 5 Sep 2025 14:23:22 +0200 Subject: [PATCH 18/20] 4565: Aligned admin changelog --- docs/v2-changelogs/admin.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/v2-changelogs/admin.md b/docs/v2-changelogs/admin.md index 0d11e15c0..b5b0eb0cb 100644 --- a/docs/v2-changelogs/admin.md +++ b/docs/v2-changelogs/admin.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.5.2] - 2025-09-04 + +- [#290](https://github.com/os2display/display-admin-client/pull/290) + - Added temporary fix that reloads the page after a screen has been saved, to ensure fresh data is fetched. + +## [2.5.1] - 2025-06-23 + - [#287](https://github.com/os2display/display-admin-client/pull/287) - Disable live slide preview for templates with option.disableLivePreview. From caf0d111184e9d3c6238503da93e4bcd4dd5e1ef Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:40:57 +0200 Subject: [PATCH 19/20] 4565: Added slide with theme fixture --- assets/shared/slide-utils/slide-util.jsx | 1 + assets/shared/templates/image-text.jsx | 14 +++----------- assets/template/fixtures/slide-fixtures.js | 21 +++++++++++++++++++++ assets/template/index.jsx | 2 +- fixtures/public/fixtures/example.css | 19 +++++++++++++++++++ 5 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 fixtures/public/fixtures/example.css diff --git a/assets/shared/slide-utils/slide-util.jsx b/assets/shared/slide-utils/slide-util.jsx index 3c61e5d6f..db445e847 100644 --- a/assets/shared/slide-utils/slide-util.jsx +++ b/assets/shared/slide-utils/slide-util.jsx @@ -63,6 +63,7 @@ function ThemeStyles({ id, css = null }) { const slideCss = css.replaceAll("#SLIDE_ID", `#${id}`); const ThemeComponent = createGlobalStyle`${slideCss}`; + return ; } diff --git a/assets/shared/templates/image-text.jsx b/assets/shared/templates/image-text.jsx index 702a15ea2..d583830d9 100644 --- a/assets/shared/templates/image-text.jsx +++ b/assets/shared/templates/image-text.jsx @@ -44,7 +44,6 @@ function ImageText({ slide, content, run, slideDone, executionId }) { const imageTimeoutRef = useRef(); const [images, setImages] = useState([]); const [currentImage, setCurrentImage] = useState(); - const [themeCss, setThemeCss] = useState(null); const logo = slide?.theme?.logo; const { showLogo, @@ -69,15 +68,6 @@ function ImageText({ slide, content, run, slideDone, executionId }) { logoClasses.push(logoPosition); } - // Set theme styles. - useEffect(() => { - if (slide?.theme?.cssStyles) { - setThemeCss( - , - ); - } - }, [slide]); - // Styling from content const { separator, @@ -257,7 +247,9 @@ function ImageText({ slide, content, run, slideDone, executionId }) { )} - {themeCss} + {slide?.theme?.cssStyles && ( + + )} ); } diff --git a/assets/template/fixtures/slide-fixtures.js b/assets/template/fixtures/slide-fixtures.js index d6d2d723d..ed1e6bc04 100644 --- a/assets/template/fixtures/slide-fixtures.js +++ b/assets/template/fixtures/slide-fixtures.js @@ -883,6 +883,27 @@ const slideFixtures = [ fontSize: "font-size-lg", }, }, + { + id: "image-text-4-test-theme", + templateData: { + id: "01FP2SNGFN0BZQH03KCBXHKYHG", + }, + themeFile: "/fixtures/example.css", + content: { + duration: 5000, + title: "Overskriften er her", + text: "Dette er brødtekst lorem ipsum dolor sit amet....", + image: [], + boxAlign: "top", + boxMargin: false, + shadow: true, + separator: false, + halfSize: false, + reversed: false, + mediaContain: true, + fontSize: "font-size-xl", + }, + }, { id: "instagram-0", templateData: { diff --git a/assets/template/index.jsx b/assets/template/index.jsx index 75291ea22..43c078c3d 100644 --- a/assets/template/index.jsx +++ b/assets/template/index.jsx @@ -70,7 +70,7 @@ export const Slide = ({ slide: inputSlide }) => { useEffect(() => { if (inputSlide !== null) { const newSlide = { ...inputSlide }; - newSlide.executionId = "" + new Date().getTime(); + newSlide.executionId = "SLIDE_ID"; // Attach theme. if (newSlide?.themeFile) { diff --git a/fixtures/public/fixtures/example.css b/fixtures/public/fixtures/example.css new file mode 100644 index 000000000..f058fc83f --- /dev/null +++ b/fixtures/public/fixtures/example.css @@ -0,0 +1,19 @@ +/* +* Example theme file +* #SLIDE_ID should always encapsulate all your theme styling +* #SLIDE_ID will be replaced at runtime with the given slide execution id to make sure the theme styling +* only applies to the given slide. +*/ + +#SLIDE_ID { + --bg-light: red; + --bg-dark: blue; + --text-light: purple; + --text-dark: green; + --text-color: yellow; +} + +#SLIDE_ID .text { + background-color: var(--bg-light); + color: var(--text-color); +} From 35ba18a5635ceefc089841c8bdbc73b3e6dc68d7 Mon Sep 17 00:00:00 2001 From: Troels Ugilt Jensen <6103205+tuj@users.noreply.github.com> Date: Fri, 5 Sep 2025 14:50:36 +0200 Subject: [PATCH 20/20] 4565: Added copy fixture assets step to playwright github actions --- .github/workflows/playwright.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index 02550e4e6..5b78012d6 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -22,6 +22,10 @@ jobs: run: | docker compose run --rm phpfpm composer install + - name: Copy fixture assets to public/fixtures + run: | + docker compose run --rm phpfpm cp -r fixtures/public/fixtures public/fixtures + - name: Build assets run: | docker compose run --rm node npm install