diff --git a/.gitignore b/.gitignore index a290f8d73..14e13f8d2 100644 --- a/.gitignore +++ b/.gitignore @@ -256,4 +256,6 @@ schema.graphql api/.claude/ +*.pdf + ui/.claude/worktrees/ diff --git a/ui/src/client.ts b/ui/src/client.ts index 1588c6387..f3eb78100 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 dc4d6e65b..1dca64790 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 @@ -24,6 +24,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'; @@ -93,6 +94,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.HomepageBanner]: BlockHomepageBanner, [ContentBlockType.HighlightText]: BlockHighlightText, [ContentBlockType.ThemeReels]: BlockThemeReels, + [ContentBlockType.OverviewThemes]: BlockOverviewThemes, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), @@ -123,6 +125,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/ContentBlockRenderer/ContentBlockRenderer.scss b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.scss index 4c64bae06..d4a99654b 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/FieldGenerator/FieldGenerator.tsx b/ui/src/react-admin/modules/content-page/components/FieldGenerator/FieldGenerator.tsx index f4e37ba55..c9df771d5 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 @@ -143,6 +143,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, @@ -152,7 +159,7 @@ export const FieldGenerator: FunctionComponent = ({ }, // biome-ignore lint/suspicious/noExplicitAny: todo innerState as any, - `${fieldKey}-${index}`, + fieldId, fieldKey, currentState ); @@ -160,21 +167,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' )} - + + {/**/} ); })} @@ -250,7 +263,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) => { @@ -269,6 +283,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/BlockImage/BlockImage.test.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockImage/BlockImage.test.tsx index 09acd2798..990ac922f 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockImage/BlockImage.test.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockImage/BlockImage.test.tsx @@ -45,9 +45,28 @@ describe('', () => { it('Should set the correct title and text', () => { render(blockImageExample); - const titleElement = screen.getByText('© example title'); + const titleElement = screen.getByText('example title'); const textElement = screen.getByText('example text'); expect(titleElement).toBeInTheDocument(); expect(textElement).toBeInTheDocument(); }); + + it('Should only show the copyright icon when copyrightIconVisible is set', () => { + render(blockImageExample); + expect(screen.getByText('example title')).not.toHaveTextContent('©'); + + cleanup(); + render( + + ); + expect(screen.getByText('© example title')).toBeInTheDocument(); + }); }); 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 000000000..57225c9d1 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts @@ -0,0 +1,119 @@ +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 { + 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: AvoCoreContentPickerType.IE_OBJECT_THEME, + value: '', +}); + +const INITIAL_OVERVIEW_THEMES_GROUP_STATE = () => ({ + title: '', + titleType: 'h2', + 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(); + +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] + ) + ), + }, + themes: { + label: tText( + 'modules/content-page/components/blocks/block-overview-themes/block-overview-themes___thema', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ContentPicker, + editorProps: { + 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', + {}, + [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 000000000..a3c60e228 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.scss @@ -0,0 +1,151 @@ +@use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/settings/colors" as colors; +@use "../../../../shared/styles/mixins/typography" as typography; +@use "../../../../shared/styles/mixins/animations" as animations; +@use "../../../../shared/styles/mixins/focus" as focus; + +.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. + clip-path: inset(0); + @include typography.sofia-pro-heading-xl; + + &__group { + position: relative; + padding: 3.2rem 2.4rem 0; + + @media (min-width: variables.$g-bp3) { + padding: 6 * variables.$g-spacer-unit 5 * variables.$g-spacer-unit 0; + } + } + + // 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-shape{ + width: 10cqw; + height: 10cqw; + background-color: colors.$white; + position: absolute; + top: 0; + right: 0; + } + + &__group-title { + position: relative; + z-index: 1; + @include typography.sofia-pro-heading-xl(true); + + // 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; + + @media (min-width: variables.$g-bp3) { + font-size: 3.2rem !important; + margin-bottom: 3.2rem; + } + } + + &__grid { + position: relative; + z-index: 1; + display: grid; + 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(3, 1fr); + grid-auto-rows: 20rem; + } + + @media (min-width: variables.$g-bp4) { + grid-auto-rows: 24rem; + } + } + + &__tile { + position: relative; + display: block; + aspect-ratio: 4/3; + overflow: hidden; + text-decoration: none; + + @media (min-width: variables.$g-bp2) { + // 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 { + @include focus.focus; + } + + &:hover &-image > img { + @include animations.zoom-in-animation; + } + + &::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%; + @include animations.zoom-in-transition; + } + } + + &__tile-title { + position: absolute; + bottom: 0; + left: 0; + z-index: 1; + padding: 3rem; + color: colors.$white; + @include typography.sofia-pro-heading-md; + @include typography.truncate-after-x-lines(2); + } +} 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 000000000..a2ea88223 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.tsx @@ -0,0 +1,39 @@ +import clsx from 'clsx'; +import type { FunctionComponent, ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { BlockOverviewThemesProps } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemes.types.ts'; +import { BlockOverviewThemesGroupSection } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemesGroupSection.tsx'; +import { GET_SECONDARY_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; +import { useGetThemesByIds } from './hooks/useGetThemesByIds'; +import './BlockOverviewThemes.scss'; + +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); + + return ( +
+ {(elements || []).map((group, groupIndex) => ( + + ))} +
+ ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.types.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.types.ts new file mode 100644 index 000000000..cb1995fba --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemes.types.ts @@ -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; + themes: Theme[]; + 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 000000000..b8d32eb4a --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/BlockOverviewThemesGroupSection.tsx @@ -0,0 +1,170 @@ +import { Image, LinkTarget } from '@viaa/avo2-components'; +import clsx from 'clsx'; +import { stringifyUrl } from 'query-string'; +import React, { + type CSSProperties, + type FunctionComponent, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { BlockOverviewThemesGroupSectionProps } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemes.types.ts'; +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'; +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import { keyBy } from 'es-toolkit/compat'; +import { BlockHeading } from '~content-blocks/BlockHeading'; +import { SmartLink } from '~shared/components/SmartLink/SmartLink.tsx'; + +/** + * 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, themes, 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 themesById = useMemo(() => keyBy(themes, (theme) => theme.id), [themes]); + 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 && ( + + {group.title} + + )} +
+ {resolvedThemes.map((theme, tileIndex) => { + const span = spans[tileIndex]; + const url = stringifyUrl({ + url: `/${ROUTE_PARTS.search}`, + query: { theme: theme.slug }, + }); + + return ( + + {theme.nameNl} + {theme.nameNl} + + ); + })} +
+
+ ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/getThemeTileSpans.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/getThemeTileSpans.test.ts new file mode 100644 index 000000000..1deac070d --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockOverviewThemes/getThemeTileSpans.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { getThemeTileSpans, type ThemeTileSpan } from './getThemeTileSpans.ts'; + +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 }; + +const COLUMN_COUNT = 3; + +interface PlacedTile extends ThemeTileSpan { + row: number; + col: number; +} + +/** + * Mimics the browser's default (non-dense) CSS grid auto-placement for a 3-column grid: + * a cursor walks forward through the cells and never moves backwards, each tile lands in the + * first position at-or-after the cursor where all the cells it needs are still free. + */ +const placeTiles = (spans: ThemeTileSpan[]): PlacedTile[] => { + const occupied = new Set(); + const key = (row: number, col: number) => `${row},${col}`; + const fits = (span: ThemeTileSpan, row: number, col: number) => { + for (let r = row; r < row + span.rowSpan; r++) { + for (let c = col; c < col + span.colSpan; c++) { + if (occupied.has(key(r, c))) { + return false; + } + } + } + return true; + }; + + let cursorRow = 0; + let cursorCol = 0; + + return spans.map((span) => { + // A tile never overflows the grid: wrap to the next row when it no longer fits horizontally. + if (cursorCol + span.colSpan > COLUMN_COUNT) { + cursorRow++; + cursorCol = 0; + } + while (!fits(span, cursorRow, cursorCol)) { + cursorCol++; + if (cursorCol + span.colSpan > COLUMN_COUNT) { + cursorRow++; + cursorCol = 0; + } + } + + for (let r = cursorRow; r < cursorRow + span.rowSpan; r++) { + for (let c = cursorCol; c < cursorCol + span.colSpan; c++) { + occupied.add(key(r, c)); + } + } + + const placed: PlacedTile = { ...span, row: cursorRow, col: cursorCol }; + cursorCol += span.colSpan; + return placed; + }); +}; + +const totalArea = (spans: ThemeTileSpan[]) => + spans.reduce((total, span) => total + span.colSpan * span.rowSpan, 0); + +describe('getThemeTileSpans', () => { + describe('degenerate counts', () => { + it('returns an empty array for 0', () => { + expect(getThemeTileSpans(0)).toEqual([]); + }); + + it('returns an empty array for negative counts', () => { + expect(getThemeTileSpans(-1)).toEqual([]); + expect(getThemeTileSpans(-42)).toEqual([]); + }); + }); + + describe('single row layouts', () => { + it('renders 1 theme as a single full width tile', () => { + expect(getThemeTileSpans(1)).toEqual([FULL_WIDTH]); + }); + + it('renders 2 themes as a square followed by a wide tile', () => { + expect(getThemeTileSpans(2)).toEqual([SQUARE, WIDE]); + }); + + it('renders 3 themes as three squares', () => { + expect(getThemeTileSpans(3)).toEqual([SQUARE, SQUARE, SQUARE]); + }); + }); + + describe('braided snake layouts', () => { + it('closes a 4 theme grid with a wide tile', () => { + expect(getThemeTileSpans(4)).toEqual([SQUARE, SQUARE, TALL, WIDE]); + }); + + it('closes a 5 theme grid with two squares', () => { + expect(getThemeTileSpans(5)).toEqual([SQUARE, SQUARE, TALL, SQUARE, SQUARE]); + }); + + it('alternates the tall tile between the outer columns', () => { + expect(getThemeTileSpans(8)).toEqual([ + SQUARE, + SQUARE, + TALL, + TALL, + SQUARE, + SQUARE, + TALL, + WIDE, + ]); + }); + + it('starts every layout above 3 themes with two squares and a tall tile', () => { + for (let count = 4; count <= 30; count++) { + expect(getThemeTileSpans(count).slice(0, 3)).toEqual([SQUARE, SQUARE, TALL]); + } + }); + }); + + describe('invariants', () => { + it('returns exactly one span per theme', () => { + for (let count = 0; count <= 50; count++) { + expect(getThemeTileSpans(count)).toHaveLength(Math.max(count, 0)); + } + }); + + it('only returns the four supported tile shapes', () => { + for (let count = 1; count <= 50; count++) { + for (const span of getThemeTileSpans(count)) { + expect([SQUARE, TALL, WIDE, FULL_WIDTH]).toContainEqual(span); + } + } + }); + + it('covers a whole number of rows worth of cells', () => { + for (let count = 1; count <= 50; count++) { + expect(totalArea(getThemeTileSpans(count)) % COLUMN_COUNT).toBe(0); + } + }); + + it('fills the grid without gaps or overlaps', () => { + for (let count = 1; count <= 50; count++) { + const spans = getThemeTileSpans(count); + const placed = placeTiles(spans); + const rowCount = totalArea(spans) / COLUMN_COUNT; + + const cells = new Set(); + for (const tile of placed) { + for (let r = tile.row; r < tile.row + tile.rowSpan; r++) { + for (let c = tile.col; c < tile.col + tile.colSpan; c++) { + // A repeated cell means two tiles overlap. + expect(cells.has(`${r},${c}`)).toBe(false); + cells.add(`${r},${c}`); + } + } + } + + // Every cell of the rectangle is covered exactly once, so there are no holes either. + expect(cells.size).toBe(rowCount * COLUMN_COUNT); + for (const tile of placed) { + expect(tile.row + tile.rowSpan).toBeLessThanOrEqual(rowCount); + expect(tile.col + tile.colSpan).toBeLessThanOrEqual(COLUMN_COUNT); + } + } + }); + + it('never places a tall tile in the middle column', () => { + for (let count = 1; count <= 50; count++) { + const tallTiles = placeTiles(getThemeTileSpans(count)).filter((tile) => tile.rowSpan === 2); + for (const tile of tallTiles) { + expect(tile.col).not.toBe(1); + } + } + }); + }); +}); 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 000000000..79ff6c5b2 --- /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; +}; 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 000000000..f5e07c3c7 --- /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 000000000..e03c509bc --- /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 4d12d7ff5..9406f588e 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 @@ -27,6 +27,7 @@ import { MAINTAINERS_GRID_BLOCK_CONFIG } from '~content-blocks/BlockMaintainersG import { MEDIA_GRID_BLOCK_CONFIG } from '~content-blocks/BlockMediaGrid'; import { OVERVIEW_NEWSPAPER_TITLES_BLOCK_CONFIG } from '~content-blocks/BlockOverviewNewspaperTitles'; import { OVERVIEW_WITH_CAROUSEL_CONFIG } from '~content-blocks/BlockOverviewWithCarousel'; +import { OVERVIEW_THEMES_BLOCK_CONFIG } from '~content-blocks/BlockOverviewThemes'; import { QUOTE_BLOCK_CONFIG } from '~content-blocks/BlockQuote'; import { RICH_TEXT_BLOCK_CONFIG, @@ -93,4 +94,5 @@ export const CONTENT_BLOCK_CONFIG_MAP: Record< [ContentBlockType.HomepageBanner]: CONTENT_HOMEPAGE_BANNER_CONFIG, [ContentBlockType.HighlightText]: CONTENT_HIGHLIGHT_TEXT_CONFIG, [ContentBlockType.ThemeReels]: THEME_REELS_BLOCK_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 47dd3bb3f..61b21ca07 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 @@ -27,6 +27,7 @@ import { INITIAL_MAINTAINERS_GRID_BLOCK_STATE } from '~content-blocks/BlockMaint import { INITIAL_MEDIA_GRID_COMPONENTS_STATE } from '~content-blocks/BlockMediaGrid'; import { INITIAL_OVERVIEW_NEWSPAPER_TITLES_BLOCK_STATE } from '~content-blocks/BlockOverviewNewspaperTitles'; import { INITIAL_OVERVIEW_WITH_CAROUSEL_BLOCK_STATE } from '~content-blocks/BlockOverviewWithCarousel/BlockOverviewWithCarousel.editorconfig.ts'; +import { INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE } from '~content-blocks/BlockOverviewThemes/BlockOverviewThemes.editorconfig.ts'; import { INITIAL_PAGE_OVERVIEW_COMPONENTS_STATE } from '~content-blocks/BlockPageOverview'; import { INITIAL_QUOTE_COMPONENTS_STATE } from '~content-blocks/BlockQuote'; import { @@ -96,4 +97,5 @@ export const CONTENT_BLOCK_INITIAL_STATE_MAP: { [ContentBlockType.HomepageBanner]: INITIAL_CONTENT_HOMEPAGE_BANNER_BLOCK_STATE, [ContentBlockType.HighlightText]: INITIAL_CONTENT_HIGHLIGHT_TEXT_BLOCK_STATE, [ContentBlockType.ThemeReels]: INITIAL_THEME_REELS_COMPONENTS_STATE, + [ContentBlockType.OverviewThemes]: INITIAL_OVERVIEW_THEMES_COMPONENTS_STATE, }; 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 d63483f47..2329efa28 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(), 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 f138c03af..18f589edc 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 @@ -206,6 +206,14 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = ]), value: ContentBlockType.ThemeReels, }, + { + 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 e91339747..f276f3ce0 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 @@ -190,6 +190,7 @@ export enum ContentBlockType { HomepageBanner = 'HOMEPAGE_BANNER', HighlightText = 'HIGHLIGHT_TEXT', ThemeReels = 'THEME_REELS', + OverviewThemes = 'OVERVIEW_THEMES', } export enum ContentBlockEditor { diff --git a/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.tsx b/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.tsx index e3dc6dd74..f62dde88f 100644 --- a/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.tsx +++ b/ui/src/react-admin/modules/shared/components/ContentPicker/ContentPicker.tsx @@ -130,6 +130,31 @@ export const ContentPicker: FunctionComponent = ({ } }, [itemOptions, hasAppliedInitialItem, value]); + // Follow the `value` prop when it is changed from the outside, eg: when an entry is deleted from + // a repeated field, the values of the pickers after the deleted one shift up one position. + // Without this the picker would keep rendering the item it was initialised with. + useEffect(() => { + if (!hasAppliedInitialItem) { + return; // The effect above is still responsible for applying the initial item + } + setSelectedItem((currentSelectedItem: PickerItem | null) => { + if ((currentSelectedItem?.value || null) === (value?.value || null)) { + return currentSelectedItem; // Already in sync, most likely this component reported the change itself + } + if (!value?.value) { + return null; + } + // Prefer the fetched option, since it is guaranteed to have an up to date label + return ( + setInitialItem(itemOptions, value) || { + label: value.label || '', + type: value.type, + value: value.value, + } + ); + }); + }, [value, itemOptions, hasAppliedInitialItem]); + // events const onSelectType = async (selected: SingleValue) => { if (selectedType !== selected) { diff --git a/ui/src/react-admin/modules/shared/helpers/sanitize/sanitize.test.ts b/ui/src/react-admin/modules/shared/helpers/sanitize/sanitize.test.ts index e5a78202d..1e86ce6f1 100644 --- a/ui/src/react-admin/modules/shared/helpers/sanitize/sanitize.test.ts +++ b/ui/src/react-admin/modules/shared/helpers/sanitize/sanitize.test.ts @@ -7,8 +7,9 @@ describe('sanitize', () => { const originalHtml = '

Simpel! Drie stappen naar een geslaagde opdracht:

'; const sanitizedHtml = sanitizeHtml(originalHtml, SanitizePreset.full); + // Attributes come back in their original order expect(sanitizedHtml).toEqual( - '

Simpel! Drie stappen naar een geslaagde opdracht:

' + '

Simpel! Drie stappen naar een geslaagde opdracht:

' ); }); 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 index 3c997a7da..2d92a7cf5 100644 --- 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 @@ -35,11 +35,14 @@ export class ThemesService { 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)); + return await fetchWithLogoutJson( + stringifyUrl({ + url: `${ThemesService.getBaseUrl()}/by-id`, + query: { + ids: ids.join(','), + }, + }) + ); } catch (err) { throw new CustomError('Failed to fetch themes by ids', err, { ids }); } diff --git a/ui/src/react-admin/modules/shared/styles/mixins/_focus.scss b/ui/src/react-admin/modules/shared/styles/mixins/_focus.scss new file mode 100644 index 000000000..4cb0fb8b5 --- /dev/null +++ b/ui/src/react-admin/modules/shared/styles/mixins/_focus.scss @@ -0,0 +1,6 @@ +@use '../../styles/settings/colors' as colors; + +@mixin focus { + outline: 2px solid colors.$color-ocean-green; + outline-offset: 2px; +} diff --git a/ui/src/react-admin/modules/shared/styles/mixins/_index.scss b/ui/src/react-admin/modules/shared/styles/mixins/_index.scss deleted file mode 100644 index 5c04df92a..000000000 --- a/ui/src/react-admin/modules/shared/styles/mixins/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@use "./klaar"; 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 3f14c38fa..4de544a45 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; +} diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index c9e68078d..2a59d0a10 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) => {