From 4b746c4cbf39172d22a736ad77e9f8cae7c2402c Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 16:08:39 +0200 Subject: [PATCH 1/6] feat(ARC-3814): theme overview block https://meemoo.atlassian.net/browse/ARC-3814 --- .gitignore | 4 + api/package-lock.json | 8 +- api/package.json | 2 +- ui/package-lock.json | 28 ++-- ui/package.json | 6 +- ui/src/client.ts | 1 + .../ContentBlockRenderer.const.tsx | 3 + .../BlockOverviewThemes.editorconfig.ts | 130 ++++++++++++++++++ .../BlockOverviewThemes.scss | 109 +++++++++++++++ .../BlockOverviewThemes.tsx | 90 ++++++++++++ .../hooks/useGetThemesByIds.ts | 12 ++ .../blocks/BlockOverviewThemes/index.ts | 6 + .../const/content-block-config-map.ts | 2 + .../const/content-block-initial-state-map.ts | 2 + .../const/get-content-block-type-options.ts | 8 ++ .../content-page/types/content-block.types.ts | 1 + .../ContentPicker/ContentPicker.const.ts | 11 ++ .../ContentPicker/item-providers/theme.ts | 32 +++++ .../services/themes-service/themes.service.ts | 47 +++++++ .../services/themes-service/themes.types.ts | 15 ++ .../react-admin/modules/shared/types/index.ts | 2 + ui/src/shared/helpers/admin-core-config.tsx | 1 + 22 files changed, 498 insertions(+), 22 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/hooks/useGetThemesByIds.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/index.ts create mode 100644 ui/src/react-admin/modules/shared/components/ContentPicker/item-providers/theme.ts create mode 100644 ui/src/react-admin/modules/shared/services/themes-service/themes.service.ts create mode 100644 ui/src/react-admin/modules/shared/services/themes-service/themes.types.ts diff --git a/.gitignore b/.gitignore index 54881ef6..14e13f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -255,3 +255,7 @@ schema.graphql /.husky api/.claude/ + +*.pdf + +ui/.claude/worktrees/ diff --git a/api/package-lock.json b/api/package-lock.json index 1a2ae3d3..ad0caa24 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -20,7 +20,7 @@ "@smithy/protocol-http": "4.1.8", "@smithy/signature-v4": "4.2.4", "@studiohyperdrive/pagination": "1.0.0", - "@viaa/avo2-types": "5.0.18", + "@viaa/avo2-types": "5.0.19", "app-root-path": "3.0.0", "blend-promise-utils": "^1.28.0", "cache-manager": "5.2.3", @@ -7026,9 +7026,9 @@ "dev": true }, "node_modules/@viaa/avo2-types": { - "version": "5.0.18", - "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-types/-/avo2-types-5.0.18.tgz", - "integrity": "sha512-C2dfThmIfv4rFkcHDl2coqsMJ2JlszPZxBjneuICU1ylGPqQxfrwC+verxJKsdypC2hR31q1lRK/83aqDTNpsg==" + "version": "5.0.19", + "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-types/-/avo2-types-5.0.19.tgz", + "integrity": "sha512-3Xu9iCLl9e44javHBmzTeQeChw5xQW4gP4mQ4Ituo9/VOYSnD6H586qBgCi2j0dGNSISNm5ojn2+ETyxB9wUuw==" }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", diff --git a/api/package.json b/api/package.json index 9c9ded66..02b1d363 100644 --- a/api/package.json +++ b/api/package.json @@ -45,7 +45,7 @@ "@smithy/protocol-http": "4.1.8", "@smithy/signature-v4": "4.2.4", "@studiohyperdrive/pagination": "1.0.0", - "@viaa/avo2-types": "5.0.18", + "@viaa/avo2-types": "5.0.19", "app-root-path": "3.0.0", "blend-promise-utils": "^1.28.0", "cache-manager": "5.2.3", diff --git a/ui/package-lock.json b/ui/package-lock.json index 15bd6106..29109d85 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -77,11 +77,11 @@ "@emotion/react": "11.14.0", "@floating-ui/react": "0.27.18", "@hookform/resolvers": "2.9.11", - "@meemoo/react-components": "6.0.7", + "@meemoo/react-components": "6.0.8", "@studiohyperdrive/pagination": "1.0.0", "@tanstack/react-query": "5.99.0", - "@viaa/avo2-components": "7.0.1", - "@viaa/avo2-types": "5.0.18", + "@viaa/avo2-components": "7.0.2", + "@viaa/avo2-types": "5.0.19", "autosize": "6.0.1", "blend-promise-utils": "1.29.2", "caniuse-lite": "1.0.30001712", @@ -2476,9 +2476,9 @@ "license": "MIT" }, "node_modules/@meemoo/react-components": { - "version": "6.0.7", - "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@meemoo/react-components/-/react-components-6.0.7.tgz", - "integrity": "sha512-WTvGXD35QfPDwB0vuA6/5kz/fhsfBLYshaYu4U8dDzGUNy/hhp1sdFHUZqqVX17yqHjIVivrrPYM4yG0RiUZ0w==", + "version": "6.0.8", + "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@meemoo/react-components/-/react-components-6.0.8.tgz", + "integrity": "sha512-kRY2WmpdH+/06sYOSbFMKVJIlj9IMbv+cOEAFd8wlGkCtZP2+YO3xo/kvJ7W5QuqkZdXTkb5yd1xjc3TaSmr4w==", "peer": true, "dependencies": { "use-sync-external-store": "1.4.0" @@ -2488,7 +2488,7 @@ "@floating-ui/react": "0.27.18", "@flowplayer/player": "3.32.1", "@tanstack/react-table": "8.21.3", - "@viaa/avo2-types": "5.0.18", + "@viaa/avo2-types": "5.0.19", "autosize": "6.0.1", "clsx": "2.0.0", "date-fns": "3.6.0", @@ -4569,13 +4569,13 @@ } }, "node_modules/@viaa/avo2-components": { - "version": "7.0.1", - "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-components/-/avo2-components-7.0.1.tgz", - "integrity": "sha512-5dsmWKlA5MzJNHBrCU197k4DYS1JmFvk0xLY09XKRgxytPMMqOKTnvvMpAQTHZYj0wsZx9Dpn/x90ehLd5+oOQ==", + "version": "7.0.2", + "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-components/-/avo2-components-7.0.2.tgz", + "integrity": "sha512-BGThaG4qtUjzyyO1ZHLPKZkayGKefN91oTerBGX6JqYGfo7gAhVLCNRo9we0JMd08s1UDNkRsCwodMvOh5qIiQ==", "peer": true, "peerDependencies": { "@popperjs/core": "2.11.6", - "@viaa/avo2-types": "5.0.18", + "@viaa/avo2-types": "5.0.19", "autosize": "6.0.1", "clsx": "2.0.0", "date-fns": "3.6.0", @@ -4592,9 +4592,9 @@ } }, "node_modules/@viaa/avo2-types": { - "version": "5.0.18", - "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-types/-/avo2-types-5.0.18.tgz", - "integrity": "sha512-C2dfThmIfv4rFkcHDl2coqsMJ2JlszPZxBjneuICU1ylGPqQxfrwC+verxJKsdypC2hR31q1lRK/83aqDTNpsg==", + "version": "5.0.19", + "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-types/-/avo2-types-5.0.19.tgz", + "integrity": "sha512-3Xu9iCLl9e44javHBmzTeQeChw5xQW4gP4mQ4Ituo9/VOYSnD6H586qBgCi2j0dGNSISNm5ojn2+ETyxB9wUuw==", "peer": true }, "node_modules/@vitejs/plugin-react": { diff --git a/ui/package.json b/ui/package.json index b087d36e..287fb4e9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -76,11 +76,11 @@ "@emotion/react": "11.14.0", "@floating-ui/react": "0.27.18", "@hookform/resolvers": "2.9.11", - "@meemoo/react-components": "6.0.7", + "@meemoo/react-components": "6.0.8", "@studiohyperdrive/pagination": "1.0.0", "@tanstack/react-query": "5.99.0", - "@viaa/avo2-components": "7.0.1", - "@viaa/avo2-types": "5.0.18", + "@viaa/avo2-components": "7.0.2", + "@viaa/avo2-types": "5.0.19", "autosize": "6.0.1", "blend-promise-utils": "1.29.2", "caniuse-lite": "1.0.30001712", diff --git a/ui/src/client.ts b/ui/src/client.ts index 1588c638..f3eb7810 100644 --- a/ui/src/client.ts +++ b/ui/src/client.ts @@ -19,6 +19,7 @@ export { BlockIntro } from '~content-blocks/BlockIntro'; export { BlockKlaar } from '~content-blocks/BlockKlaar'; export { BlockMaintainersGrid } from '~content-blocks/BlockMaintainersGrid'; export { BlockOverviewNewspaperTitles } from '~content-blocks/BlockOverviewNewspaperTitles'; +export { BlockOverviewThemes } from '~content-blocks/BlockOverviewThemes'; export { BlockOverviewWithCarousel } from '~content-blocks/BlockOverviewWithCarousel'; export { BlockPageOverviewWrapper } from '~content-blocks/BlockPageOverview/BlockPageOverview.wrapper'; export { BlockQuote } from '~content-blocks/BlockQuote'; diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx index 2b41e4d2..d9179c4f 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx @@ -22,6 +22,7 @@ import { BlockIntro } from '~content-blocks/BlockIntro'; import { BlockKlaar } from '~content-blocks/BlockKlaar'; import { BlockMaintainersGrid } from '~content-blocks/BlockMaintainersGrid'; import { BlockOverviewNewspaperTitles } from '~content-blocks/BlockOverviewNewspaperTitles'; +import { BlockOverviewThemes } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemes.tsx'; import { BlockOverviewWithCarousel } from '~content-blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.tsx'; import { BlockPageOverviewWrapper } from '~content-blocks/BlockPageOverview'; import { BlockQuote } from '~content-blocks/BlockQuote'; @@ -87,6 +88,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.Breadcrumbs]: BlockBreadcrumbs, [ContentBlockType.HetArchiefImageTextBackground]: BlockHetArchiefImageTextBackground, [ContentBlockType.OverviewWithCarousel]: BlockOverviewWithCarousel, + [ContentBlockType.OverviewThemes]: BlockOverviewThemes, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), @@ -111,6 +113,7 @@ export const REPEATABLE_CONTENT_BLOCKS = [ ContentBlockType.ThreeClickableTiles, ContentBlockType.TagsWithLink, ContentBlockType.CardsWithoutDescription, + ContentBlockType.OverviewThemes, // ContentBlockType.ContentEncloseGrid, ]; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts new file mode 100644 index 00000000..4da4c8d9 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts @@ -0,0 +1,130 @@ +import { + BACKGROUND_COLOR_FIELD, + BLOCK_FIELD_DEFAULTS, + BLOCK_STATE_DEFAULTS, + TEXT_FIELD, +} from '~content-blocks/defaults'; +import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; +import { GET_FULL_HEADING_TYPE_OPTIONS } from '~modules/content-page/const/get-heading-type-options'; +import { THEME_CONTENT_PICKER_TYPE } from '~modules/shared/components/ContentPicker/item-providers/theme'; +import { + Color, + type ContentBlockConfig, + ContentBlockEditor, + ContentBlockType, + type DefaultContentBlockState, +} from '~modules/content-page/types/content-block.types'; +import { tText } from '~shared/helpers/translation-functions'; +import { validateRequiredValue } from '~shared/helpers/validation'; +import { HET_ARCHIEF } from '~shared/types'; + +const INITIAL_OVERVIEW_THEMES_THEME_STATE = () => ({ + label: '', + type: THEME_CONTENT_PICKER_TYPE, + value: '', +}); + +export const INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE = () => ({ + title: '', + titleType: 'h2', + backgroundColor: Color.Transparent, + themes: [INITIAL_OVERVIEW_THEMES_THEME_STATE()], +}); + +export const INITIAL_OVERVIEW_THEMES_BLOCK_STATE = (): DefaultContentBlockState => + BLOCK_STATE_DEFAULTS(); + +export const OVERVIEW_THEMES_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ + position, + name: tText('modules/content-page/const/get-content-block-type-options___overzicht-themas', {}, [ + HET_ARCHIEF, + ]), + type: ContentBlockType.OverviewThemes, + components: { + name: tText( + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___themagroep', + {}, + [HET_ARCHIEF] + ), + limits: { + min: 1, + }, + state: INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE(), + fields: { + title: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___titel', + {}, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___titel-is-verplicht', + {}, + [HET_ARCHIEF] + ) + ), + titleType: { + label: tText( + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___titel-type', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.Select, + editorProps: { + options: GET_FULL_HEADING_TYPE_OPTIONS(), + }, + validator: (value: string) => + validateRequiredValue( + value, + tText( + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___titel-type-is-verplicht', + {}, + [HET_ARCHIEF] + ) + ), + }, + backgroundColor: BACKGROUND_COLOR_FIELD( + tText( + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___achtergrondkleur', + {}, + [HET_ARCHIEF] + ), + GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[1] + ), + themes: { + label: tText( + "modules/content-page/components/blocks/block-overview-themes/block-overview-themes___thema", + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ContentPicker, + editorProps: { + allowedTypes: [THEME_CONTENT_PICKER_TYPE], + hideTypeDropdown: true, + hideTargetSwitch: true, + }, + repeat: { + defaultState: INITIAL_OVERVIEW_THEMES_THEME_STATE(), + addButtonLabel: tText( + "modules/content-page/components/blocks/block-overview-themes/block-overview-themes___voeg-thema-toe", + {}, + [HET_ARCHIEF] + ), + deleteButtonLabel: tText( + "modules/content-page/components/blocks/block-overview-themes/block-overview-themes___verwijder-thema", + {}, + [HET_ARCHIEF] + ), + }, + }, + }, + }, + block: { + state: INITIAL_OVERVIEW_THEMES_BLOCK_STATE(), + fields: { + ...BLOCK_FIELD_DEFAULTS(), + }, + }, +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss new file mode 100644 index 00000000..7ebdfe1c --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss @@ -0,0 +1,109 @@ +@use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/settings/colors" as colors; + +.c-block-overview-themes { + display: flex; + flex-direction: column; + gap: 3.2rem; + + &__group { + padding: 3.2rem 2.4rem; + + @media (min-width: variables.$g-bp3) { + padding: 4.8rem 4rem; + } + } + + &__group-title { + color: colors.$color-gray-1000; + font-family: SofiaPro, sans-serif; + font-size: 2.4rem; + font-style: normal; + font-weight: 800; + margin-bottom: 2.4rem; + + @media (min-width: variables.$g-bp3) { + font-size: 3.2rem; + margin-bottom: 3.2rem; + } + } + + &__grid { + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: 1.6rem; + + @media (min-width: variables.$g-bp2) { + grid-template-columns: repeat(2, 1fr); + gap: 2rem; + } + + @media (min-width: variables.$g-bp4) { + grid-template-columns: repeat(3, 1fr); + gap: 2.4rem; + } + } + + &__tile { + position: relative; + display: block; + aspect-ratio: 4/3; + overflow: hidden; + border-radius: 0.4rem; + text-decoration: none; + + @media (min-width: variables.$g-bp2) { + aspect-ratio: 16/10; + } + + &:focus-visible { + outline: 2px solid colors.$color-ocean-green; + outline-offset: 2px; + } + + &:hover &-image > img { + transform: scale(1.08); + } + + &::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgb(0 0 0 / 0%) 40%, rgb(0 0 0 / 72%) 100%); + pointer-events: none; + } + } + + &__tile-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + + > img { + object-fit: cover; + object-position: center; + width: 100%; + height: 100%; + transition: transform 0.5s ease-out; + } + } + + &__tile-title { + position: relative; + z-index: 1; + padding: 1.6rem 2rem; + color: colors.$white; + font-family: SofiaPro, sans-serif; + font-size: 1.8rem; + font-style: normal; + font-weight: 800; + line-height: 2.4rem; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx new file mode 100644 index 00000000..4c0ef98c --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx @@ -0,0 +1,90 @@ +import { Image } from '@viaa/avo2-components'; +import clsx from 'clsx'; +import { keyBy } from 'es-toolkit'; +import { stringifyUrl } from 'query-string'; +import type { FunctionComponent, ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { BlockHeading } from '~content-blocks/BlockHeading'; +import type { Color, HeadingTypeOption } from '~modules/content-page/types/content-block.types'; +import { Link } from '~shared/components/Link/Link'; +import { ROUTE_PARTS } from '~shared/consts/routes'; +import type { DefaultComponentProps } from '~shared/types/components'; +import type { PickerItem } from '~shared/types/content-picker'; +import './BlockOverviewThemes.scss'; +import { useGetThemesByIds } from './hooks/useGetThemesByIds'; + +export interface BlockOverviewThemesGroup { + title: string; + titleType: HeadingTypeOption; + backgroundColor: Color; + themes: PickerItem[]; +} + +export interface BlockOverviewThemesProps extends DefaultComponentProps { + elements: BlockOverviewThemesGroup[]; +} + +export const BlockOverviewThemes: FunctionComponent = ({ + className, + elements, +}): ReactElement => { + const themeIds = useMemo( + () => + Array.from( + new Set((elements || []).flatMap((group) => (group.themes || []).map((theme) => theme.value))) + ), + [elements] + ); + const { data: themes } = useGetThemesByIds(themeIds); + const themesById = useMemo(() => keyBy(themes || [], (theme) => theme.id), [themes]); + + return ( +
+ {(elements || []).map((group, groupIndex) => ( +
+ {group.title && ( + + {group.title} + + )} +
+ {(group.themes || []).map((pickerItem, tileIndex) => { + const theme = themesById[pickerItem.value]; + + if (!theme) { + return null; + } + + return ( + + {theme.nameNl} + {theme.nameNl} + + ); + })} +
+
+ ))} +
+ ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/hooks/useGetThemesByIds.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/hooks/useGetThemesByIds.ts new file mode 100644 index 00000000..f5e07c3c --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/hooks/useGetThemesByIds.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; +import { ThemesService } from '~shared/services/themes-service/themes.service'; +import type { Theme } from '~shared/services/themes-service/themes.types'; +import { QUERY_KEYS } from '~shared/types'; + +export const useGetThemesByIds = (ids: string[]) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_THEMES_BY_IDS, ...ids], + queryFn: () => ThemesService.fetchThemesByIds(ids), + enabled: ids.length > 0, + }); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/index.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/index.ts new file mode 100644 index 00000000..e03c509b --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/index.ts @@ -0,0 +1,6 @@ +export { BlockOverviewThemes } from './BlockOverviewThemes'; +export { + INITIAL_OVERVIEW_THEMES_BLOCK_STATE, + INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE, + OVERVIEW_THEMES_BLOCK_CONFIG, +} from './BlockOverviewThemes.editorconfig'; diff --git a/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts b/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts index 470d2a55..fc4595c5 100644 --- a/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts +++ b/ui/src/react-admin/modules/content-page/const/content-block-config-map.ts @@ -24,6 +24,7 @@ import { KLAAR_BLOCK_CONFIG } from '~content-blocks/BlockKlaar'; import { MAINTAINERS_GRID_BLOCK_CONFIG } from '~content-blocks/BlockMaintainersGrid'; import { MEDIA_GRID_BLOCK_CONFIG } from '~content-blocks/BlockMediaGrid'; import { OVERVIEW_NEWSPAPER_TITLES_BLOCK_CONFIG } from '~content-blocks/BlockOverviewNewspaperTitles'; +import { OVERVIEW_THEMES_BLOCK_CONFIG } from '~content-blocks/BlockOverviewThemes'; import { CONTENT_OVERVIEW_WITH_CAROUSEL_CONFIG } from '~content-blocks/BlockOverviewWithCarousel'; import { QUOTE_BLOCK_CONFIG } from '~content-blocks/BlockQuote'; import { @@ -87,4 +88,5 @@ export const CONTENT_BLOCK_CONFIG_MAP: Record< [ContentBlockType.Breadcrumbs]: CONTENT_BREADCRUMBS_CONFIG, [ContentBlockType.ScrollDownNudge]: CONTENT_SCROLL_DOWN_NUDGE_CONFIG, [ContentBlockType.OverviewWithCarousel]: CONTENT_OVERVIEW_WITH_CAROUSEL_CONFIG, + [ContentBlockType.OverviewThemes]: OVERVIEW_THEMES_BLOCK_CONFIG, }; diff --git a/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts b/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts index a864713a..a44a6e77 100644 --- a/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts +++ b/ui/src/react-admin/modules/content-page/const/content-block-initial-state-map.ts @@ -24,6 +24,7 @@ import { INITIAL_KLAAR_COMPONENTS_STATE } from '~content-blocks/BlockKlaar'; import { INITIAL_MAINTAINERS_GRID_BLOCK_STATE } from '~content-blocks/BlockMaintainersGrid'; import { INITIAL_MEDIA_GRID_COMPONENTS_STATE } from '~content-blocks/BlockMediaGrid'; import { INITIAL_OVERVIEW_NEWSPAPER_TITLES_BLOCK_STATE } from '~content-blocks/BlockOverviewNewspaperTitles'; +import { INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts'; import { INITIAL_CONTENT_OVERVIEW_WITH_CAROUSEL_BLOCK_STATE } from '~content-blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.editorconfig.ts'; import { INITIAL_PAGE_OVERVIEW_COMPONENTS_STATE } from '~content-blocks/BlockPageOverview'; import { INITIAL_QUOTE_COMPONENTS_STATE } from '~content-blocks/BlockQuote'; @@ -90,4 +91,5 @@ export const CONTENT_BLOCK_INITIAL_STATE_MAP: { [ContentBlockType.Breadcrumbs]: INITIAL_BREADCRUMBS_BLOCK_STATE, [ContentBlockType.ScrollDownNudge]: INITIAL_SCROLL_DOWN_NUDGE_BLOCK_STATE, [ContentBlockType.OverviewWithCarousel]: INITIAL_CONTENT_OVERVIEW_WITH_CAROUSEL_BLOCK_STATE, + [ContentBlockType.OverviewThemes]: INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE, }; diff --git a/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts b/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts index 94bcf7c7..5929b958 100644 --- a/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts +++ b/ui/src/react-admin/modules/content-page/const/get-content-block-type-options.ts @@ -184,6 +184,14 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = ), value: ContentBlockType.OverviewWithCarousel, }, + { + label: tText( + "modules/content-page/const/get-content-block-type-options___overzicht-themas", + {}, + [HET_ARCHIEF] + ), + value: ContentBlockType.OverviewThemes, + }, ]; // Only show the content blocks that the client enabled through the config object diff --git a/ui/src/react-admin/modules/content-page/types/content-block.types.ts b/ui/src/react-admin/modules/content-page/types/content-block.types.ts index 534dfefa..4e3cb7ec 100644 --- a/ui/src/react-admin/modules/content-page/types/content-block.types.ts +++ b/ui/src/react-admin/modules/content-page/types/content-block.types.ts @@ -175,6 +175,7 @@ export enum ContentBlockType { AvoImageTextBackground = 'AVO_IMAGE_TEXT_BACKGROUND', // Avo ScrollDownNudge = 'SCROLL_DOWN_NUDGE', OverviewWithCarousel = 'OVERVIEW_WITH_CAROUSEL', + OverviewThemes = 'OVERVIEW_THEMES', } export enum ContentBlockEditor { diff --git a/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts b/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts index d1063ec9..2173665c 100644 --- a/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts +++ b/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts @@ -11,6 +11,7 @@ import { retrieveContentPages, retrieveProjectContentPages } from './item-provid import { retrieveInternalLinks } from './item-providers/internal-link'; import { retrieveItems } from './item-providers/item'; import { retrieveProfiles } from './item-providers/profile'; +import { retrieveThemes, THEME_CONTENT_PICKER_TYPE } from './item-providers/theme'; export interface PickerItem { label?: string; @@ -85,6 +86,9 @@ export const GET_CONTENT_TYPE_LABELS: () => Record PickerTypeOption[] = () => { fetch: retrieveIeObjects, picker: 'SELECT', }, + { + value: THEME_CONTENT_PICKER_TYPE, + label: labels[THEME_CONTENT_PICKER_TYPE], + disabled: false, + fetch: retrieveThemes, + picker: 'SELECT', + }, getContentTypeForContentPage(AvoCoreContentPickerType.CONTENT_PAGE_NEWS_ITEM), getContentTypeForContentPage(AvoCoreContentPickerType.CONTENT_PAGE_PAGE), getContentTypeForContentPage(AvoCoreContentPickerType.CONTENT_PAGE_PROJECT), diff --git a/ui/src/react-admin/modules/shared/components/ContentPicker/item-providers/theme.ts b/ui/src/react-admin/modules/shared/components/ContentPicker/item-providers/theme.ts new file mode 100644 index 00000000..398135ad --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/ContentPicker/item-providers/theme.ts @@ -0,0 +1,32 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import memoize from 'memoizee'; +import { MEMOIZEE_OPTIONS } from '~shared/consts/memoizee-options'; +import { CustomError } from '~shared/helpers/custom-error'; +import { ThemesService } from '~shared/services/themes-service/themes.service'; +import type { Theme } from '~shared/services/themes-service/themes.types'; +import type { PickerItem } from '~shared/types/content-picker'; +import { parsePickerItem } from '../helpers/parse-picker'; + +export const retrieveThemes = memoize( + async (titleOrId: string | null, limit = 5): Promise => { + try { + const response = await ThemesService.fetchThemes(titleOrId, 0, limit); + return parseThemes(response.items || []); + } catch (err) { + throw new CustomError('Failed to fetch themes for content picker', err, { + titleOrId, + limit, + }); + } + }, + MEMOIZEE_OPTIONS +); + +const parseThemes = (raw: Theme[]): PickerItem[] => { + return raw.map( + (theme: Theme): PickerItem => ({ + label: theme.nameNl, + ...parsePickerItem(AvoCoreContentPickerType.IE_OBJECT_THEME, theme.id), + }) + ); +}; diff --git a/ui/src/react-admin/modules/shared/services/themes-service/themes.service.ts b/ui/src/react-admin/modules/shared/services/themes-service/themes.service.ts new file mode 100644 index 00000000..1553ed26 --- /dev/null +++ b/ui/src/react-admin/modules/shared/services/themes-service/themes.service.ts @@ -0,0 +1,47 @@ +import { stringifyUrl } from 'query-string'; +import { CustomError } from '~shared/helpers/custom-error'; +import { fetchWithLogoutJson } from '~shared/helpers/fetch-with-logout'; +import { getProxyUrl } from '~shared/helpers/get-proxy-url-from-admin-core-config'; +import type { Theme, ThemesResponse } from './themes.types'; + +export class ThemesService { + private static getBaseUrl(): string { + return `${getProxyUrl()}/themes`; + } + + public static async fetchThemes( + searchTerm: string | null = null, + page = 0, + size = 20 + ): Promise { + try { + return await fetchWithLogoutJson( + stringifyUrl({ + url: ThemesService.getBaseUrl(), + query: { + searchTerm: searchTerm || undefined, + page, + size, + }, + }) + ); + } catch (err) { + throw new CustomError('Failed to fetch themes', err, { searchTerm, page, size }); + } + } + + public static async fetchThemesByIds(ids: string[]): Promise { + if (!ids.length) { + return []; + } + try { + // The themes endpoint doesn't support filtering by ids, so we fetch a large page and filter client-side. + // Themes are a bounded editorial taxonomy, so this is expected to stay small. + const response = await ThemesService.fetchThemes(null, 0, 1000); + const idSet = new Set(ids); + return response.items.filter((theme) => idSet.has(theme.id)); + } catch (err) { + throw new CustomError('Failed to fetch themes by ids', err, { ids }); + } + } +} diff --git a/ui/src/react-admin/modules/shared/services/themes-service/themes.types.ts b/ui/src/react-admin/modules/shared/services/themes-service/themes.types.ts new file mode 100644 index 00000000..311f5fe7 --- /dev/null +++ b/ui/src/react-admin/modules/shared/services/themes-service/themes.types.ts @@ -0,0 +1,15 @@ +export interface Theme { + id: string; + slug: string; + nameNl: string; + nameEn: string; + imageUrl: string | null; +} + +export interface ThemesResponse { + items: Theme[]; + total: number; + pages: number; + page: number; + size: number; +} diff --git a/ui/src/react-admin/modules/shared/types/index.ts b/ui/src/react-admin/modules/shared/types/index.ts index 7e8af39b..0eb2fc4a 100644 --- a/ui/src/react-admin/modules/shared/types/index.ts +++ b/ui/src/react-admin/modules/shared/types/index.ts @@ -40,4 +40,6 @@ export enum QUERY_KEYS { GET_IE_OBJECT = 'GET_IE_OBJECT', GET_TABLE_COLUMN_PREFERENCE = 'GET_TABLE_COLUMN_PREFERENCE', GET_NAMES_BY_PROFILE_IDS = 'GET_NAMES_BY_PROFILE_IDS', + GET_THEMES = 'GET_THEMES', + GET_THEMES_BY_IDS = 'GET_THEMES_BY_IDS', } diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index c9e68078..2a59d0a1 100644 --- a/ui/src/shared/helpers/admin-core-config.tsx +++ b/ui/src/shared/helpers/admin-core-config.tsx @@ -97,6 +97,7 @@ export function getAdminCoreConfigForLocalTestApp(navigateFunc: NavigateFunction // ContentBlockType.ContentEncloseGrid, // ContentBlockType.Breadcrumbs, ContentBlockType.OverviewWithCarousel, + ContentBlockType.OverviewThemes, ], defaultPageWidth: ContentPageWidth.LARGE, onSaveContentPage: async (contentPageInfo: ContentPageInfo) => { From be0e3f49ae146d3612c99dd61cefd4f71d53350b Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 17:37:42 +0200 Subject: [PATCH 2/6] feat(ARC-3814): basic styling fixes https://meemoo.atlassian.net/browse/ARC-3814 --- .../FieldGenerator/FieldGenerator.tsx | 47 ++++++++++++------- .../BlockOverviewThemes.editorconfig.ts | 37 +++++---------- .../BlockOverviewThemes.scss | 25 +++++----- .../BlockOverviewThemes.tsx | 21 ++++----- .../ContentPicker/ContentPicker.const.ts | 12 ++--- .../shared/styles/utilities/_spacer.scss | 14 ++++++ 6 files changed, 84 insertions(+), 72 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/FieldGenerator/FieldGenerator.tsx b/ui/src/react-admin/modules/content-page/components/FieldGenerator/FieldGenerator.tsx index 986ec6f8..4d7b7a96 100644 --- a/ui/src/react-admin/modules/content-page/components/FieldGenerator/FieldGenerator.tsx +++ b/ui/src/react-admin/modules/content-page/components/FieldGenerator/FieldGenerator.tsx @@ -142,6 +142,13 @@ export const FieldGenerator: FunctionComponent = ({ <> {/* biome-ignore lint/suspicious/noExplicitAny: todo */} {currentState.map((innerState: any, index: number) => { + const fieldId = `${fieldKey}-${index}`; + const defaultProps = { + ...field.editorProps, + editorId: fieldId, + name: fieldId, + }; + // biome-ignore lint/suspicious/noExplicitAny: todo const editorProps: any = generateFieldAttributes( field as ContentBlockField, @@ -151,7 +158,7 @@ export const FieldGenerator: FunctionComponent = ({ }, // biome-ignore lint/suspicious/noExplicitAny: todo innerState as any, - `${fieldKey}-${index}`, + fieldId, fieldKey, currentState ); @@ -159,21 +166,27 @@ export const FieldGenerator: FunctionComponent = ({ return ( // biome-ignore lint/suspicious/noArrayIndexKey: We don't have a better key at this time - - - - - - - {renderNote(field)} - - - {currentState.length > 1 && ( - - {renderDeleteButton(currentState, field?.repeat?.deleteButtonLabel, index)} - + {/**/} + {/* /!* `center` + `orientation="vertical"` renders as flex-direction: column with*/} + {/* align-items: center, which centers this item at its content width instead of*/} + {/* stretching it — force it to take the full row width regardless. *!/*/} + {/* */} + + + + + {renderNote(field)} + + {/**/} + {currentState.length > 1 && + renderDeleteButton( + currentState, + field?.repeat?.deleteButtonLabel, + index, + 'u-align-right' )} - + + {/**/} ); })} @@ -249,7 +262,8 @@ export const FieldGenerator: FunctionComponent = ({ // biome-ignore lint/suspicious/noExplicitAny: todo stateCopy: any, label?: string, - index?: number + index?: number, + className?: string ) => { // biome-ignore lint/suspicious/noExplicitAny: todo const handleFieldDelete = (index: any) => { @@ -268,6 +282,7 @@ export const FieldGenerator: FunctionComponent = ({ title={label} ariaLabel={label} type="danger" + className={className} /> ); }; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts index 4da4c8d9..57225c9d 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts @@ -1,14 +1,7 @@ -import { - BACKGROUND_COLOR_FIELD, - BLOCK_FIELD_DEFAULTS, - BLOCK_STATE_DEFAULTS, - TEXT_FIELD, -} from '~content-blocks/defaults'; -import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import { BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, TEXT_FIELD } from '~content-blocks/defaults'; import { GET_FULL_HEADING_TYPE_OPTIONS } from '~modules/content-page/const/get-heading-type-options'; -import { THEME_CONTENT_PICKER_TYPE } from '~modules/shared/components/ContentPicker/item-providers/theme'; import { - Color, type ContentBlockConfig, ContentBlockEditor, ContentBlockType, @@ -20,17 +13,21 @@ import { HET_ARCHIEF } from '~shared/types'; const INITIAL_OVERVIEW_THEMES_THEME_STATE = () => ({ label: '', - type: THEME_CONTENT_PICKER_TYPE, + type: AvoCoreContentPickerType.IE_OBJECT_THEME, value: '', }); -export const INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE = () => ({ +const INITIAL_OVERVIEW_THEMES_GROUP_STATE = () => ({ title: '', titleType: 'h2', - backgroundColor: Color.Transparent, themes: [INITIAL_OVERVIEW_THEMES_THEME_STATE()], }); +// `components.state` for a repeatable block must be an array: the editor pushes/splices entries +// into it directly (see content-edit.reducer.ts), and `ContentBlockRenderer` passes it straight +// through as the `elements` prop. +export const INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE = () => [INITIAL_OVERVIEW_THEMES_GROUP_STATE()]; + export const INITIAL_OVERVIEW_THEMES_BLOCK_STATE = (): DefaultContentBlockState => BLOCK_STATE_DEFAULTS(); @@ -85,35 +82,27 @@ export const OVERVIEW_THEMES_BLOCK_CONFIG = (position = 0): ContentBlockConfig = ) ), }, - backgroundColor: BACKGROUND_COLOR_FIELD( - tText( - 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___achtergrondkleur', - {}, - [HET_ARCHIEF] - ), - GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[1] - ), themes: { label: tText( - "modules/content-page/components/blocks/block-overview-themes/block-overview-themes___thema", + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___thema', {}, [HET_ARCHIEF] ), editorType: ContentBlockEditor.ContentPicker, editorProps: { - allowedTypes: [THEME_CONTENT_PICKER_TYPE], + allowedTypes: [AvoCoreContentPickerType.IE_OBJECT_THEME], hideTypeDropdown: true, hideTargetSwitch: true, }, repeat: { defaultState: INITIAL_OVERVIEW_THEMES_THEME_STATE(), addButtonLabel: tText( - "modules/content-page/components/blocks/block-overview-themes/block-overview-themes___voeg-thema-toe", + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___voeg-thema-toe', {}, [HET_ARCHIEF] ), deleteButtonLabel: tText( - "modules/content-page/components/blocks/block-overview-themes/block-overview-themes___verwijder-thema", + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___verwijder-thema', {}, [HET_ARCHIEF] ), diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss index 7ebdfe1c..08d1bfc7 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss @@ -2,9 +2,11 @@ @use "../../../../shared/styles/settings/colors" as colors; .c-block-overview-themes { - display: flex; - flex-direction: column; - gap: 3.2rem; + font-family: SofiaPro, sans-serif; + font-size: 3.2rem; + font-style: normal; + font-weight: 800; + line-height: 4rem; &__group { padding: 3.2rem 2.4rem; @@ -15,12 +17,9 @@ } &__group-title { + // Reset so the heading level (h1-h4, chosen for semantics only) never affects the visual style. + margin: 0 0 2.4rem; color: colors.$color-gray-1000; - font-family: SofiaPro, sans-serif; - font-size: 2.4rem; - font-style: normal; - font-weight: 800; - margin-bottom: 2.4rem; @media (min-width: variables.$g-bp3) { font-size: 3.2rem; @@ -31,16 +30,13 @@ &__grid { display: grid; grid-template-columns: repeat(1, 1fr); - gap: 1.6rem; @media (min-width: variables.$g-bp2) { grid-template-columns: repeat(2, 1fr); - gap: 2rem; } @media (min-width: variables.$g-bp4) { grid-template-columns: repeat(3, 1fr); - gap: 2.4rem; } } @@ -49,7 +45,6 @@ display: block; aspect-ratio: 4/3; overflow: hidden; - border-radius: 0.4rem; text-decoration: none; @media (min-width: variables.$g-bp2) { @@ -90,9 +85,11 @@ } &__tile-title { - position: relative; + position: absolute; + bottom: 0; + left: 0; z-index: 1; - padding: 1.6rem 2rem; + padding: 4.8rem 4.8rem; color: colors.$white; font-family: SofiaPro, sans-serif; font-size: 1.8rem; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx index 4c0ef98c..3f08628f 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx @@ -4,8 +4,7 @@ import { keyBy } from 'es-toolkit'; import { stringifyUrl } from 'query-string'; import type { FunctionComponent, ReactElement } from 'react'; import React, { useMemo } from 'react'; -import { BlockHeading } from '~content-blocks/BlockHeading'; -import type { Color, HeadingTypeOption } from '~modules/content-page/types/content-block.types'; +import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types'; import { Link } from '~shared/components/Link/Link'; import { ROUTE_PARTS } from '~shared/consts/routes'; import type { DefaultComponentProps } from '~shared/types/components'; @@ -16,7 +15,6 @@ import { useGetThemesByIds } from './hooks/useGetThemesByIds'; export interface BlockOverviewThemesGroup { title: string; titleType: HeadingTypeOption; - backgroundColor: Color; themes: PickerItem[]; } @@ -45,16 +43,15 @@ export const BlockOverviewThemes: FunctionComponent = // biome-ignore lint/suspicious/noArrayIndexKey: groups have no stable id key={`c-block-overview-themes__group-${groupIndex}`} className="c-block-overview-themes__group" - style={{ backgroundColor: group.backgroundColor }} > - {group.title && ( - - {group.title} - - )} + {group.title && + // Rendered as the semantic titleType tag directly (not via BlockHeading) so the + // heading level only affects HTML semantics, never the visual style. + React.createElement( + group.titleType || 'h2', + { className: 'c-block-overview-themes__group-title' }, + group.title + )}
{(group.themes || []).map((pickerItem, tileIndex) => { const theme = themesById[pickerItem.value]; diff --git a/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts b/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts index 2173665c..0e0fc5f2 100644 --- a/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts +++ b/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.const.ts @@ -11,7 +11,7 @@ import { retrieveContentPages, retrieveProjectContentPages } from './item-provid import { retrieveInternalLinks } from './item-providers/internal-link'; import { retrieveItems } from './item-providers/item'; import { retrieveProfiles } from './item-providers/profile'; -import { retrieveThemes, THEME_CONTENT_PICKER_TYPE } from './item-providers/theme'; +import { retrieveThemes } from './item-providers/theme'; export interface PickerItem { label?: string; @@ -80,15 +80,15 @@ export const GET_CONTENT_TYPE_LABELS: () => Record PickerTypeOption[] = () => { picker: 'SELECT', }, { - value: THEME_CONTENT_PICKER_TYPE, - label: labels[THEME_CONTENT_PICKER_TYPE], + value: AvoCoreContentPickerType.IE_OBJECT_THEME, + label: labels[AvoCoreContentPickerType.IE_OBJECT_THEME], disabled: false, fetch: retrieveThemes, picker: 'SELECT', diff --git a/ui/src/react-admin/modules/shared/styles/utilities/_spacer.scss b/ui/src/react-admin/modules/shared/styles/utilities/_spacer.scss index 3f14c38f..4de544a4 100644 --- a/ui/src/react-admin/modules/shared/styles/utilities/_spacer.scss +++ b/ui/src/react-admin/modules/shared/styles/utilities/_spacer.scss @@ -214,3 +214,17 @@ $g-spacer-unit: 0.8rem !default; .u-padding-bottom-xl { padding-bottom: variables.$g-spacer-unit * 6 !important; } + +// align + +.u-align-right { + margin-left: auto; + margin-right: 0; + display: block !important; +} + +.u-align-left { + margin-left: 0; + margin-right: auto; + display: block !important; +} From 71119ac6f47e4f63cda7badcbf32eec7e98f13e4 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 18:18:36 +0200 Subject: [PATCH 3/6] feat(ARC-3814): add theme tiles layout https://meemoo.atlassian.net/browse/ARC-3814 --- .../BlockOverviewThemes.scss | 35 +++++++-- .../BlockOverviewThemes.tsx | 77 ++++++++++++------- .../BlockOverviewThemes/getThemeTileSpans.ts | 66 ++++++++++++++++ 3 files changed, 147 insertions(+), 31 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/getThemeTileSpans.ts diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss index 08d1bfc7..7200918b 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss @@ -29,14 +29,17 @@ &__grid { display: grid; - grid-template-columns: repeat(1, 1fr); + grid-template-columns: 1fr; + // The "snake" tile spans (see getThemeTileSpans.ts) only make sense in a 3-column grid; + // below this breakpoint every tile is forced back to a single stacked column. @media (min-width: variables.$g-bp2) { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 20rem; } @media (min-width: variables.$g-bp4) { - grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 24rem; } } @@ -48,7 +51,29 @@ text-decoration: none; @media (min-width: variables.$g-bp2) { - aspect-ratio: 16/10; + // Size comes from the grid row/column spans below instead of a fixed aspect ratio. + aspect-ratio: auto; + grid-column: span 1; + grid-row: span 1; + + &--tall { + grid-row: span 2; + } + + &--wide { + grid-column: span 2; + } + + &--full-width { + grid-column: span 3; + } + } + + // Below the breakpoint the grid collapses to a single column: neutralise any computed + // span so every tile is still a simple, plainly-stacked full-width card. + @media (max-width: variables.$g-bp2 - 1) { + grid-column: span 1 !important; + grid-row: span 1 !important; } &:focus-visible { @@ -89,7 +114,7 @@ bottom: 0; left: 0; z-index: 1; - padding: 4.8rem 4.8rem; + padding: 4.8rem; color: colors.$white; font-family: SofiaPro, sans-serif; font-size: 1.8rem; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx index 3f08628f..5a64e25e 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx @@ -7,11 +7,28 @@ import React, { useMemo } from 'react'; import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types'; import { Link } from '~shared/components/Link/Link'; import { ROUTE_PARTS } from '~shared/consts/routes'; +import type { Theme } from '~shared/services/themes-service/themes.types'; import type { DefaultComponentProps } from '~shared/types/components'; import type { PickerItem } from '~shared/types/content-picker'; import './BlockOverviewThemes.scss'; +import { type ThemeTileSpan, getThemeTileSpans } from './getThemeTileSpans'; import { useGetThemesByIds } from './hooks/useGetThemesByIds'; +// Only 4 combinations occur (see getThemeTileSpans): 1x1 needs no modifier, the rest map to a +// fixed CSS class since `Link` (an app-provided router link) doesn't accept an inline `style`. +const getTileSpanClassName = (span: ThemeTileSpan): string | undefined => { + if (span.colSpan === 1 && span.rowSpan === 2) { + return 'c-block-overview-themes__tile--tall'; + } + if (span.colSpan === 2 && span.rowSpan === 1) { + return 'c-block-overview-themes__tile--wide'; + } + if (span.colSpan === 3 && span.rowSpan === 1) { + return 'c-block-overview-themes__tile--full-width'; + } + return undefined; +}; + export interface BlockOverviewThemesGroup { title: string; titleType: HeadingTypeOption; @@ -52,34 +69,42 @@ export const BlockOverviewThemes: FunctionComponent = { className: 'c-block-overview-themes__group-title' }, group.title )} -
- {(group.themes || []).map((pickerItem, tileIndex) => { - const theme = themesById[pickerItem.value]; + {(() => { + const resolvedThemes = (group.themes || []) + .map((pickerItem) => themesById[pickerItem.value]) + .filter((theme): theme is Theme => !!theme); + const spans = getThemeTileSpans(resolvedThemes.length); - if (!theme) { - return null; - } + return ( +
+ {resolvedThemes.map((theme, tileIndex) => { + const span = spans[tileIndex]; - return ( - - {theme.nameNl} - {theme.nameNl} - - ); - })} -
+ return ( + + {theme.nameNl} + {theme.nameNl} + + ); + })} +
+ ); + })()} ))}
diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/getThemeTileSpans.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/getThemeTileSpans.ts new file mode 100644 index 00000000..79ff6c5b --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/getThemeTileSpans.ts @@ -0,0 +1,66 @@ +export interface ThemeTileSpan { + colSpan: 1 | 2 | 3; + rowSpan: 1 | 2; +} + +const SQUARE: ThemeTileSpan = { colSpan: 1, rowSpan: 1 }; +const TALL: ThemeTileSpan = { colSpan: 1, rowSpan: 2 }; +const WIDE: ThemeTileSpan = { colSpan: 2, rowSpan: 1 }; +const FULL_WIDTH: ThemeTileSpan = { colSpan: 3, rowSpan: 1 }; + +/** + * Computes a "braided snake" layout for a 3-column grid: a chain of tall tiles alternates + * between the outer columns (each overlapping the row below), the middle column is always a + * plain square, and the final row closes out the grid with either 2 squares or 1 wide tile — + * whichever exactly uses up the remaining items, so the grid is always a filled rectangle. + * + * This relies on the browser's default (non-dense) CSS grid auto-placement: as long as tiles + * are rendered in the order returned here, each one lands in the correct cell on its own — + * no explicit grid-column/grid-row line numbers are needed. + */ +export const getThemeTileSpans = (count: number): ThemeTileSpan[] => { + if (count <= 0) { + return []; + } + if (count === 1) { + return [FULL_WIDTH]; + } + + const spans: ThemeTileSpan[] = []; + let remaining = count; + + // Row 0: no incoming tall continuation yet. + if (remaining > 3) { + spans.push(SQUARE, SQUARE, TALL); + remaining -= 3; + } else if (remaining === 3) { + spans.push(SQUARE, SQUARE, SQUARE); + return spans; + } else { + // remaining === 2 + spans.push(SQUARE, WIDE); + return spans; + } + + // Subsequent rows: the previous row's tall reserves one outer column, so the next empty + // cell alternates between "the other outer column" and "the middle column" first. + let tallLandsFirst = true; + while (remaining > 2) { + if (tallLandsFirst) { + spans.push(TALL, SQUARE); + } else { + spans.push(SQUARE, TALL); + } + tallLandsFirst = !tallLandsFirst; + remaining -= 2; + } + + if (remaining === 2) { + spans.push(SQUARE, SQUARE); + } else { + // remaining === 1 + spans.push(WIDE); + } + + return spans; +}; From d3758e598ba77e2ee5a67de86e537e49d753955a Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 18:40:00 +0200 Subject: [PATCH 4/6] feat(ARC-3814): add colored bg behind the theme group titles https://meemoo.atlassian.net/browse/ARC-3814 --- .../BlockOverviewThemes.scss | 21 +++ .../BlockOverviewThemes.tsx | 168 +++++++++++++----- .../content-page/const/get-color-options.ts | 26 +-- 3 files changed, 156 insertions(+), 59 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss index 7200918b..da79b578 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss @@ -2,6 +2,9 @@ @use "../../../../shared/styles/settings/colors" as colors; .c-block-overview-themes { + // The group band breaks out to a fixed 5000px width for full-bleed coverage; it must stay + // visible here so it isn't clipped by this element's own bounds. + overflow-x: visible; font-family: SofiaPro, sans-serif; font-size: 3.2rem; font-style: normal; @@ -9,6 +12,7 @@ line-height: 4rem; &__group { + position: relative; padding: 3.2rem 2.4rem; @media (min-width: variables.$g-bp3) { @@ -16,7 +20,22 @@ } } + // Full-bleed colored band behind the title + first grid row. Its height is measured from + // the real rendered layout (see BlockOverviewThemes.tsx) since a "tall" tile can span from + // row 1 into row 2. The 5000px width is just a generous fixed size to guarantee it always + // covers the viewport, breaking out of any max-width content container. + &__group-band { + position: absolute; + top: 0; + left: -2500px; + z-index: 0; + width: 5000px; + } + &__group-title { + position: relative; + z-index: 1; + // Reset so the heading level (h1-h4, chosen for semantics only) never affects the visual style. margin: 0 0 2.4rem; color: colors.$color-gray-1000; @@ -28,6 +47,8 @@ } &__grid { + position: relative; + z-index: 1; display: grid; grid-template-columns: 1fr; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx index 5a64e25e..35e1f443 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx @@ -3,7 +3,8 @@ import clsx from 'clsx'; import { keyBy } from 'es-toolkit'; import { stringifyUrl } from 'query-string'; import type { FunctionComponent, ReactElement } from 'react'; -import React, { useMemo } from 'react'; +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types'; import { Link } from '~shared/components/Link/Link'; import { ROUTE_PARTS } from '~shared/consts/routes'; @@ -14,6 +15,26 @@ import './BlockOverviewThemes.scss'; import { type ThemeTileSpan, getThemeTileSpans } from './getThemeTileSpans'; import { useGetThemesByIds } from './hooks/useGetThemesByIds'; +// Assigns one color per group without repeating any color until the whole palette has been used +// once ("shuffle bag"): pick a random color from the remaining pool, remove it, and refill the +// pool with the full palette again once it runs out. +const assignBandColors = (groupCount: number): string[] => { + const palette = GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF().map((option) => option.value as string); + const colors: string[] = []; + let pool: string[] = []; + + for (let i = 0; i < groupCount; i++) { + if (pool.length === 0) { + pool = [...palette]; + } + const index = Math.floor(Math.random() * pool.length); + colors.push(pool[index]); + pool.splice(index, 1); + } + + return colors; +}; + // Only 4 combinations occur (see getThemeTileSpans): 1x1 needs no modifier, the rest map to a // fixed CSS class since `Link` (an app-provided router link) doesn't accept an inline `style`. const getTileSpanClassName = (span: ThemeTileSpan): string | undefined => { @@ -39,6 +60,96 @@ export interface BlockOverviewThemesProps extends DefaultComponentProps { elements: BlockOverviewThemesGroup[]; } +interface BlockOverviewThemesGroupSectionProps { + group: BlockOverviewThemesGroup; + groupIndex: number; + themesById: Record; + bandColor: string; +} + +/** + * Renders a single theme group with a full-bleed colored band behind the title and first grid + * row. Since a "tall" tile can genuinely span from row 1 into row 2, the grid can't be split into + * separate "row 1" and "rest" DOM trees without breaking that span, so the band's height is + * measured from the actual rendered layout instead of hardcoded. + */ +const BlockOverviewThemesGroupSection: FunctionComponent = ({ + group, + groupIndex, + themesById, + bandColor, +}) => { + const gridRef = useRef(null); + const [bandHeight, setBandHeight] = useState(null); + + useLayoutEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) { + return; + } + + const measure = () => { + const firstRowHeight = Number.parseFloat( + getComputedStyle(gridEl).gridTemplateRows.split(' ')[0] || '0' + ); + setBandHeight(firstRowHeight ? gridEl.offsetTop + firstRowHeight : null); + }; + + measure(); + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(gridEl); + return () => resizeObserver.disconnect(); + }, []); + + const resolvedThemes = (group.themes || []) + .map((pickerItem) => themesById[pickerItem.value]) + .filter((theme): theme is Theme => !!theme); + const spans = getThemeTileSpans(resolvedThemes.length); + + return ( +
+ {bandHeight !== null && ( +
+ )} + {group.title && + // Rendered as the semantic titleType tag directly (not via BlockHeading) so the + // heading level only affects HTML semantics, never the visual style. + React.createElement( + group.titleType || 'h2', + { className: 'c-block-overview-themes__group-title' }, + group.title + )} +
+ {resolvedThemes.map((theme, tileIndex) => { + const span = spans[tileIndex]; + + return ( + + {theme.nameNl} + {theme.nameNl} + + ); + })} +
+
+ ); +}; + export const BlockOverviewThemes: FunctionComponent = ({ className, elements, @@ -52,60 +163,19 @@ export const BlockOverviewThemes: FunctionComponent = ); const { data: themes } = useGetThemesByIds(themeIds); const themesById = useMemo(() => keyBy(themes || [], (theme) => theme.id), [themes]); + const bandColors = useMemo(() => assignBandColors((elements || []).length), [elements]); return (
{(elements || []).map((group, groupIndex) => ( -
- {group.title && - // Rendered as the semantic titleType tag directly (not via BlockHeading) so the - // heading level only affects HTML semantics, never the visual style. - React.createElement( - group.titleType || 'h2', - { className: 'c-block-overview-themes__group-title' }, - group.title - )} - {(() => { - const resolvedThemes = (group.themes || []) - .map((pickerItem) => themesById[pickerItem.value]) - .filter((theme): theme is Theme => !!theme); - const spans = getThemeTileSpans(resolvedThemes.length); - - return ( -
- {resolvedThemes.map((theme, tileIndex) => { - const span = spans[tileIndex]; - - return ( - - {theme.nameNl} - {theme.nameNl} - - ); - })} -
- ); - })()} -
+ group={group} + groupIndex={groupIndex} + themesById={themesById} + bandColor={bandColors[groupIndex]} + /> ))}
); diff --git a/ui/src/react-admin/modules/content-page/const/get-color-options.ts b/ui/src/react-admin/modules/content-page/const/get-color-options.ts index d63483f4..2329efa2 100644 --- a/ui/src/react-admin/modules/content-page/const/get-color-options.ts +++ b/ui/src/react-admin/modules/content-page/const/get-color-options.ts @@ -130,18 +130,9 @@ export const GET_BACKGROUND_COLOR_OPTIONS_AVO: () => SelectOption[] = () yellowOption(), ]; -export const GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF: () => SelectOption< +export const GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF: () => SelectOption< Color | GradientColor | CustomBackground >[] = () => [ - transparentOption(), - whiteOption(), - oceanGreenOption(), - seaGreenOption(), - platinumOption(), - blackOption(), - skyBlueOption(), - meemooLogoOption(), - blackWhiteGradientOption(), oldPinkOption(), lavenderOption(), lilaOption(), @@ -154,6 +145,21 @@ export const GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF: () => SelectOption< mustardOption(), ]; +export const GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF: () => SelectOption< + Color | GradientColor | CustomBackground +>[] = () => [ + transparentOption(), + whiteOption(), + oceanGreenOption(), + seaGreenOption(), + platinumOption(), + blackOption(), + skyBlueOption(), + meemooLogoOption(), + blackWhiteGradientOption(), + ...GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF(), +]; + export const GET_AVO_HERO_BACKGROUND_COLOR_OPTIONS: () => SelectOption[] = () => [ softBlueOption(), nightBlueOption(), From c2701452f6007ecd1f70d6201ad709f031092fe7 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 19:22:12 +0200 Subject: [PATCH 5/6] feat(ARC-3814): add shapes in theme group title bg https://meemoo.atlassian.net/browse/ARC-3814 --- .../ContentBlockRenderer.scss | 4 ++ .../BlockOverviewThemes.scss | 15 +++- .../BlockOverviewThemes.tsx | 71 ++++++++++++++++--- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss index 4c64bae0..d4a99654 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss @@ -40,6 +40,10 @@ } } +.c-content-block-preview { + overflow-x: hidden; +} + .c-content-block-preview--dark { // Here you can overwrite styles on content-blocks for previews with a dark background .c-block-media-list { diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss index da79b578..9b48fd62 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss @@ -13,10 +13,10 @@ &__group { position: relative; - padding: 3.2rem 2.4rem; + padding: 3.2rem 2.4rem 0; @media (min-width: variables.$g-bp3) { - padding: 4.8rem 4rem; + padding: 4.8rem 4rem 0; } } @@ -32,6 +32,15 @@ width: 5000px; } + &__group-shape{ + width: 10cqw; + height: 10cqw; + background-color: colors.$white; + position: absolute; + top: 0; + right: 0; + } + &__group-title { position: relative; z-index: 1; @@ -135,7 +144,7 @@ bottom: 0; left: 0; z-index: 1; - padding: 4.8rem; + padding: 3rem; color: colors.$white; font-family: SofiaPro, sans-serif; font-size: 1.8rem; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx index 35e1f443..f35e3f93 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx @@ -2,7 +2,7 @@ import { Image } from '@viaa/avo2-components'; import clsx from 'clsx'; import { keyBy } from 'es-toolkit'; import { stringifyUrl } from 'query-string'; -import type { FunctionComponent, ReactElement } from 'react'; +import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types'; @@ -12,14 +12,16 @@ import type { Theme } from '~shared/services/themes-service/themes.types'; import type { DefaultComponentProps } from '~shared/types/components'; import type { PickerItem } from '~shared/types/content-picker'; import './BlockOverviewThemes.scss'; -import { type ThemeTileSpan, getThemeTileSpans } from './getThemeTileSpans'; +import { getThemeTileSpans, type ThemeTileSpan } from './getThemeTileSpans'; import { useGetThemesByIds } from './hooks/useGetThemesByIds'; // Assigns one color per group without repeating any color until the whole palette has been used // once ("shuffle bag"): pick a random color from the remaining pool, remove it, and refill the // pool with the full palette again once it runs out. const assignBandColors = (groupCount: number): string[] => { - const palette = GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF().map((option) => option.value as string); + const palette = GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF().map( + (option) => option.value as string + ); const colors: string[] = []; let pool: string[] = []; @@ -106,13 +108,62 @@ const BlockOverviewThemesGroupSection: FunctionComponent !!theme); const spans = getThemeTileSpans(resolvedThemes.length); + /** + * Renders the white meemoo logo shapes in the colors bands behind the theme group title and first row + * @param groupIndex + */ + const renderGroupShapes = (groupIndex: number) => { + const rectangleStyles: CSSProperties = { width: '6cqw' }; + const circleStyles: CSSProperties = { borderRadius: '50%' }; + const shapeStyles: [CSSProperties, CSSProperties][] = [ + [circleStyles, rectangleStyles], + [rectangleStyles, circleStyles], + [rectangleStyles, rectangleStyles], + ]; + const positionStyles: [CSSProperties, CSSProperties][] = [ + [ + // Circle + { right: '5%', top: '6rem' }, + // Rectangle + { right: '25%', top: '3rem', transform: 'rotate(35deg)' }, + ], + [ + // Rectangle + { right: '0%', top: '5rem', transform: 'rotate(-45deg)' }, + // Circle + { right: '25%', top: '6rem' }, + ], + [ + // Rectangle + { right: '30%', top: '1rem', transform: 'rotate(85deg)' }, + // Rectangle + { right: '45%', top: '-4rem', transform: 'rotate(20deg)' }, + ], + ]; + return ( + <> +
+
+ + ); + }; + return (
- {bandHeight !== null && ( -
+ {!!bandHeight && ( + <> +
+ {renderGroupShapes(groupIndex)} + )} {group.title && // Rendered as the semantic titleType tag directly (not via BlockHeading) so the @@ -157,7 +208,9 @@ export const BlockOverviewThemes: FunctionComponent = const themeIds = useMemo( () => Array.from( - new Set((elements || []).flatMap((group) => (group.themes || []).map((theme) => theme.value))) + new Set( + (elements || []).flatMap((group) => (group.themes || []).map((theme) => theme.value)) + ) ), [elements] ); From d716d5955d99bf5a5034726b100196f1712fced1 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 19:31:29 +0200 Subject: [PATCH 6/6] feat(ARC-3814): move theme group section to separate component https://meemoo.atlassian.net/browse/ARC-3814 --- .../BlockOverviewThemes.tsx | 206 +----------------- .../BlockOverviewThemes.types.tsx | 21 ++ .../BlockOverviewThemesGroupSection.tsx | 155 +++++++++++++ 3 files changed, 182 insertions(+), 200 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.types.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemesGroupSection.tsx diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx index f35e3f93..d2d6fdbd 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx @@ -1,205 +1,12 @@ -import { Image } from '@viaa/avo2-components'; import clsx from 'clsx'; import { keyBy } from 'es-toolkit'; -import { stringifyUrl } from 'query-string'; -import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; -import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { FunctionComponent, ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { BlockOverviewThemesProps } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemes.types.tsx'; +import { BlockOverviewThemesGroupSection } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemesGroupSection.tsx'; import { GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; -import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types'; -import { Link } from '~shared/components/Link/Link'; -import { ROUTE_PARTS } from '~shared/consts/routes'; -import type { Theme } from '~shared/services/themes-service/themes.types'; -import type { DefaultComponentProps } from '~shared/types/components'; -import type { PickerItem } from '~shared/types/content-picker'; -import './BlockOverviewThemes.scss'; -import { getThemeTileSpans, type ThemeTileSpan } from './getThemeTileSpans'; import { useGetThemesByIds } from './hooks/useGetThemesByIds'; - -// Assigns one color per group without repeating any color until the whole palette has been used -// once ("shuffle bag"): pick a random color from the remaining pool, remove it, and refill the -// pool with the full palette again once it runs out. -const assignBandColors = (groupCount: number): string[] => { - const palette = GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF().map( - (option) => option.value as string - ); - const colors: string[] = []; - let pool: string[] = []; - - for (let i = 0; i < groupCount; i++) { - if (pool.length === 0) { - pool = [...palette]; - } - const index = Math.floor(Math.random() * pool.length); - colors.push(pool[index]); - pool.splice(index, 1); - } - - return colors; -}; - -// Only 4 combinations occur (see getThemeTileSpans): 1x1 needs no modifier, the rest map to a -// fixed CSS class since `Link` (an app-provided router link) doesn't accept an inline `style`. -const getTileSpanClassName = (span: ThemeTileSpan): string | undefined => { - if (span.colSpan === 1 && span.rowSpan === 2) { - return 'c-block-overview-themes__tile--tall'; - } - if (span.colSpan === 2 && span.rowSpan === 1) { - return 'c-block-overview-themes__tile--wide'; - } - if (span.colSpan === 3 && span.rowSpan === 1) { - return 'c-block-overview-themes__tile--full-width'; - } - return undefined; -}; - -export interface BlockOverviewThemesGroup { - title: string; - titleType: HeadingTypeOption; - themes: PickerItem[]; -} - -export interface BlockOverviewThemesProps extends DefaultComponentProps { - elements: BlockOverviewThemesGroup[]; -} - -interface BlockOverviewThemesGroupSectionProps { - group: BlockOverviewThemesGroup; - groupIndex: number; - themesById: Record; - bandColor: string; -} - -/** - * Renders a single theme group with a full-bleed colored band behind the title and first grid - * row. Since a "tall" tile can genuinely span from row 1 into row 2, the grid can't be split into - * separate "row 1" and "rest" DOM trees without breaking that span, so the band's height is - * measured from the actual rendered layout instead of hardcoded. - */ -const BlockOverviewThemesGroupSection: FunctionComponent = ({ - group, - groupIndex, - themesById, - bandColor, -}) => { - const gridRef = useRef(null); - const [bandHeight, setBandHeight] = useState(null); - - useLayoutEffect(() => { - const gridEl = gridRef.current; - if (!gridEl) { - return; - } - - const measure = () => { - const firstRowHeight = Number.parseFloat( - getComputedStyle(gridEl).gridTemplateRows.split(' ')[0] || '0' - ); - setBandHeight(firstRowHeight ? gridEl.offsetTop + firstRowHeight : null); - }; - - measure(); - const resizeObserver = new ResizeObserver(measure); - resizeObserver.observe(gridEl); - return () => resizeObserver.disconnect(); - }, []); - - const resolvedThemes = (group.themes || []) - .map((pickerItem) => themesById[pickerItem.value]) - .filter((theme): theme is Theme => !!theme); - const spans = getThemeTileSpans(resolvedThemes.length); - - /** - * Renders the white meemoo logo shapes in the colors bands behind the theme group title and first row - * @param groupIndex - */ - const renderGroupShapes = (groupIndex: number) => { - const rectangleStyles: CSSProperties = { width: '6cqw' }; - const circleStyles: CSSProperties = { borderRadius: '50%' }; - const shapeStyles: [CSSProperties, CSSProperties][] = [ - [circleStyles, rectangleStyles], - [rectangleStyles, circleStyles], - [rectangleStyles, rectangleStyles], - ]; - const positionStyles: [CSSProperties, CSSProperties][] = [ - [ - // Circle - { right: '5%', top: '6rem' }, - // Rectangle - { right: '25%', top: '3rem', transform: 'rotate(35deg)' }, - ], - [ - // Rectangle - { right: '0%', top: '5rem', transform: 'rotate(-45deg)' }, - // Circle - { right: '25%', top: '6rem' }, - ], - [ - // Rectangle - { right: '30%', top: '1rem', transform: 'rotate(85deg)' }, - // Rectangle - { right: '45%', top: '-4rem', transform: 'rotate(20deg)' }, - ], - ]; - return ( - <> -
-
- - ); - }; - - return ( -
- {!!bandHeight && ( - <> -
- {renderGroupShapes(groupIndex)} - - )} - {group.title && - // Rendered as the semantic titleType tag directly (not via BlockHeading) so the - // heading level only affects HTML semantics, never the visual style. - React.createElement( - group.titleType || 'h2', - { className: 'c-block-overview-themes__group-title' }, - group.title - )} -
- {resolvedThemes.map((theme, tileIndex) => { - const span = spans[tileIndex]; - - return ( - - {theme.nameNl} - {theme.nameNl} - - ); - })} -
-
- ); -}; +import './BlockOverviewThemes.scss'; export const BlockOverviewThemes: FunctionComponent = ({ className, @@ -216,7 +23,6 @@ export const BlockOverviewThemes: FunctionComponent = ); const { data: themes } = useGetThemesByIds(themeIds); const themesById = useMemo(() => keyBy(themes || [], (theme) => theme.id), [themes]); - const bandColors = useMemo(() => assignBandColors((elements || []).length), [elements]); return (
@@ -227,7 +33,7 @@ export const BlockOverviewThemes: FunctionComponent = group={group} groupIndex={groupIndex} themesById={themesById} - bandColor={bandColors[groupIndex]} + bandColor={GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[groupIndex].value} /> ))}
diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.types.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.types.tsx new file mode 100644 index 00000000..0fde559d --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.types.tsx @@ -0,0 +1,21 @@ +import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types.ts'; +import type { Theme } from '~modules/shared/services/themes-service/themes.types'; +import type { DefaultComponentProps } from '~shared/types'; +import type { PickerItem } from '~shared/types/content-picker.ts'; + +export interface BlockOverviewThemesGroup { + title: string; + titleType: HeadingTypeOption; + themes: PickerItem[]; +} + +export interface BlockOverviewThemesProps extends DefaultComponentProps { + elements: BlockOverviewThemesGroup[]; +} + +export interface BlockOverviewThemesGroupSectionProps { + group: BlockOverviewThemesGroup; + groupIndex: number; + themesById: Record; + bandColor: string; +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemesGroupSection.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemesGroupSection.tsx new file mode 100644 index 00000000..dca5b7a7 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemesGroupSection.tsx @@ -0,0 +1,155 @@ +import { Image } from '@viaa/avo2-components'; +import clsx from 'clsx'; +import { stringifyUrl } from 'query-string'; +import type { CSSProperties, FunctionComponent } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; +import type { BlockOverviewThemesGroupSectionProps } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemes.types.tsx'; +import { Link } from '~shared/components/Link/Link'; +import { ROUTE_PARTS } from '~shared/consts/routes'; +import type { Theme } from '~shared/services/themes-service/themes.types'; +import { getThemeTileSpans, type ThemeTileSpan } from './getThemeTileSpans'; +import './BlockOverviewThemes.scss'; + +/** + * Renders a single theme group with a full-bleed colored band behind the title and first grid + * row. Since a "tall" tile can genuinely span from row 1 into row 2, the grid can't be split into + * separate "row 1" and "rest" DOM trees without breaking that span, so the band's height is + * measured from the actual rendered layout instead of hardcoded. + */ +export const BlockOverviewThemesGroupSection: FunctionComponent< + BlockOverviewThemesGroupSectionProps +> = ({ group, groupIndex, themesById, bandColor }) => { + const gridRef = useRef(null); + const [bandHeight, setBandHeight] = useState(null); + + useLayoutEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) { + return; + } + + const measure = () => { + const firstRowHeight = Number.parseFloat( + getComputedStyle(gridEl).gridTemplateRows.split(' ')[0] || '0' + ); + setBandHeight(firstRowHeight ? gridEl.offsetTop + firstRowHeight : null); + }; + + measure(); + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(gridEl); + return () => resizeObserver.disconnect(); + }, []); + + // Only 4 combinations occur (see getThemeTileSpans): 1x1 needs no modifier, the rest map to a + // fixed CSS class since `Link` (an app-provided router link) doesn't accept an inline `style`. + const getTileSpanClassName = (span: ThemeTileSpan): string | undefined => { + if (span.colSpan === 1 && span.rowSpan === 2) { + return 'c-block-overview-themes__tile--tall'; + } + if (span.colSpan === 2 && span.rowSpan === 1) { + return 'c-block-overview-themes__tile--wide'; + } + if (span.colSpan === 3 && span.rowSpan === 1) { + return 'c-block-overview-themes__tile--full-width'; + } + return undefined; + }; + + const resolvedThemes = (group.themes || []) + .map((pickerItem) => themesById[pickerItem.value]) + .filter((theme): theme is Theme => !!theme); + const spans = getThemeTileSpans(resolvedThemes.length); + + /** + * Renders the white meemoo logo shapes in the colors bands behind the theme group title and first row + * @param groupIndex + */ + const renderGroupShapes = (groupIndex: number) => { + const rectangleStyles: CSSProperties = { width: '6cqw' }; + const circleStyles: CSSProperties = { borderRadius: '50%' }; + const shapeStyles: [CSSProperties, CSSProperties][] = [ + [circleStyles, rectangleStyles], + [rectangleStyles, circleStyles], + [rectangleStyles, rectangleStyles], + ]; + const positionStyles: [CSSProperties, CSSProperties][] = [ + [ + // Circle + { right: '5%', top: '6rem' }, + // Rectangle + { right: '25%', top: '3rem', transform: 'rotate(35deg)' }, + ], + [ + // Rectangle + { right: '0%', top: '5rem', transform: 'rotate(-45deg)' }, + // Circle + { right: '25%', top: '6rem' }, + ], + [ + // Rectangle + { right: '30%', top: '1rem', transform: 'rotate(85deg)' }, + // Rectangle + { right: '45%', top: '-4rem', transform: 'rotate(20deg)' }, + ], + ]; + return ( + <> +
+
+ + ); + }; + + return ( +
+ {!!bandHeight && ( + <> +
+ {renderGroupShapes(groupIndex)} + + )} + {group.title && + // Rendered as the semantic titleType tag directly (not via BlockHeading) so the + // heading level only affects HTML semantics, never the visual style. + React.createElement( + group.titleType || 'h2', + { className: 'c-block-overview-themes__group-title' }, + group.title + )} +
+ {resolvedThemes.map((theme, tileIndex) => { + const span = spans[tileIndex]; + + return ( + + {theme.nameNl} + {theme.nameNl} + + ); + })} +
+
+ ); +};