From 94534b0751a27caad14a06b4daae20f2f6a1f83a Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 16:11:36 +0200 Subject: [PATCH 01/12] feat(ARC-3815): add timeline block https://meemoo.atlassian.net/browse/ARC-3815 --- .../ContentBlockRenderer.const.tsx | 3 + .../BlockTimeline.editorconfig.ts | 262 ++++++++++++++++++ .../blocks/BlockTimeline/BlockTimeline.scss | 144 ++++++++++ .../blocks/BlockTimeline/BlockTimeline.tsx | 211 ++++++++++++++ .../hooks/useGetTimelineIeObject.ts | 92 ++++++ .../components/blocks/BlockTimeline/index.ts | 2 + .../const/content-block-config-map.ts | 2 + .../const/content-block-initial-state-map.ts | 2 + .../const/get-content-block-type-options.ts | 8 + .../const/get-timeline-sort-order-options.ts | 23 ++ .../content-page/types/content-block.types.ts | 26 +- ui/src/shared/helpers/admin-core-config.tsx | 1 + ui/src/shared/translations/hetArchief/nl.json | 26 ++ 13 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/index.ts create mode 100644 ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts 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..bb89a78c 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 @@ -30,6 +30,7 @@ import { BlockScrollDownNudge } from '~content-blocks/BlockScrollDownNudge'; import { BlockProjectSpotlightWrapper, BlockSpotlight } from '~content-blocks/BlockSpotlight'; import { BlockTagsWithLink } from '~content-blocks/BlockTagsWithLink'; import { BlockThreeClickableTiles } from '~content-blocks/BlockThreeClickableTiles'; +import { BlockTimeline } from '~content-blocks/BlockTimeline'; import { BlockUitgeklaard } from '~content-blocks/BlockUitgeklaard'; import { BlockVideoWrapper } from '~content-blocks/BlockVideo'; import { BlockVideoTitleTextButtonWrapper } from '~content-blocks/BlockVideoTitleTextButton'; @@ -87,6 +88,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.Breadcrumbs]: BlockBreadcrumbs, [ContentBlockType.HetArchiefImageTextBackground]: BlockHetArchiefImageTextBackground, [ContentBlockType.OverviewWithCarousel]: BlockOverviewWithCarousel, + [ContentBlockType.Timeline]: BlockTimeline, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), @@ -111,6 +113,7 @@ export const REPEATABLE_CONTENT_BLOCKS = [ ContentBlockType.ThreeClickableTiles, ContentBlockType.TagsWithLink, ContentBlockType.CardsWithoutDescription, + ContentBlockType.Timeline, // ContentBlockType.ContentEncloseGrid, ]; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts new file mode 100644 index 00000000..f5570e0b --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts @@ -0,0 +1,262 @@ +import type { CheckboxProps, SelectOption } from '@viaa/avo2-components'; +import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; +import { GET_TIMELINE_SORT_ORDER_OPTIONS } from '~modules/content-page/const/get-timeline-sort-order-options'; +import type { FileUploadProps } from '~shared/components/FileUpload/FileUpload'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; +import type { + ContentBlockConfig, + ContentBlockField, + TimelineBlockState, + TimelineNodeBlockComponentState, + TimelineNodeVisualType, +} from '../../../types/content-block.types'; +import { Color, ContentBlockEditor, ContentBlockType } from '../../../types/content-block.types'; + +import { BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, FILE_FIELD, TEXT_FIELD } from '../defaults'; + +const GET_TIMELINE_NODE_VISUAL_TYPE_OPTIONS = (): SelectOption[] => [ + { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___geen', + {}, + [HET_ARCHIEF] + ), + value: 'NONE', + }, + { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___object', + {}, + [HET_ARCHIEF] + ), + value: 'OBJECT', + }, + { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___afbeelding', + {}, + [HET_ARCHIEF] + ), + value: 'IMAGE', + }, +]; + +const visualTypeIsObject: ContentBlockField['isVisible'] = (_config, formGroupState) => + (formGroupState as TimelineNodeBlockComponentState).visualType === 'OBJECT'; + +const visualTypeIsImage: ContentBlockField['isVisible'] = (_config, formGroupState) => + (formGroupState as TimelineNodeBlockComponentState).visualType === 'IMAGE'; + +export const INITIAL_TIMELINE_COMPONENTS_STATE = (): TimelineNodeBlockComponentState[] => [ + { + date: '', + title: '', + text: '', + visualType: 'NONE', + mediaItem: undefined, + image: undefined, + imageAlt: '', + imageCaptionCopyright: '', + imageCaptionCopyrightIconVisible: true, + imageCaptionDescription: '', + backgroundColor: Color.Transparent, + }, +]; + +export const INITIAL_TIMELINE_BLOCK_STATE = (): TimelineBlockState => ({ + ...BLOCK_STATE_DEFAULTS(), + sortOrder: 'date__desc', +}); + +export const TIMELINE_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ + position, + name: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___tijdslijn', + {}, + [HET_ARCHIEF] + ), + type: ContentBlockType.Timeline, + components: { + name: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___node', + {}, + [HET_ARCHIEF] + ), + state: INITIAL_TIMELINE_COMPONENTS_STATE(), + fields: { + date: { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___datum', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.DatePicker, + validator: (value: string) => + value + ? [] + : [ + tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___datum-is-verplicht', + {}, + [HET_ARCHIEF] + ), + ], + }, + title: TEXT_FIELD( + { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___titel', + {}, + [HET_ARCHIEF] + ), + }, + tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___titel-is-verplicht', + {}, + [HET_ARCHIEF] + ) + ), + text: TEXT_FIELD({ + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___tekst', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.RICH_TEXT_EDITOR, + validator: undefined, + }), + visualType: { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___visuele-elementen', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.Select, + editorProps: { + options: GET_TIMELINE_NODE_VISUAL_TYPE_OPTIONS(), + }, + validator: (value: string) => + value + ? [] + : [ + tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___keuze-van-visuele-elementen-is-verplicht', + {}, + [HET_ARCHIEF] + ), + ], + }, + mediaItem: { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___object-audio-video-of-krant', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ContentPicker, + editorProps: { + allowedTypes: ['IE_OBJECT'], + hideTargetSwitch: true, + }, + isVisible: visualTypeIsObject, + validator: (value: unknown) => + value + ? [] + : [ + tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___selecteren-van-een-object-is-verplicht', + {}, + [HET_ARCHIEF] + ), + ], + }, + image: FILE_FIELD( + tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___een-afbeelding-is-verplicht', + {}, + [HET_ARCHIEF] + ), + { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___afbeelding', + {}, + [HET_ARCHIEF] + ), + editorProps: { + assetType: 'CONTENT_BLOCK_IMAGE', + allowMulti: false, + showDeleteButton: true, + } as FileUploadProps, + isVisible: visualTypeIsImage, + } + ), + imageAlt: TEXT_FIELD({ + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___alternatieve-tekst-afbeelding', + {}, + [HET_ARCHIEF] + ), + validator: undefined, + isVisible: visualTypeIsImage, + }), + imageCaptionCopyright: TEXT_FIELD({ + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-copyright', + {}, + [HET_ARCHIEF] + ), + validator: undefined, + isVisible: visualTypeIsImage, + }), + imageCaptionCopyrightIconVisible: { + editorType: ContentBlockEditor.Checkbox, + editorProps: { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___copyright-icoon-toevoegen', + {}, + [HET_ARCHIEF] + ), + } as CheckboxProps, + isVisible: visualTypeIsImage, + }, + imageCaptionDescription: TEXT_FIELD({ + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-beschrijving', + {}, + [HET_ARCHIEF] + ), + validator: undefined, + isVisible: visualTypeIsImage, + }), + backgroundColor: { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___achtergrondkleur-van-de-node', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ColorSelect, + editorProps: { + options: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF(), + defaultValue: GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[0], + }, + }, + }, + }, + block: { + state: INITIAL_TIMELINE_BLOCK_STATE(), + fields: { + ...BLOCK_FIELD_DEFAULTS(), + sortOrder: { + label: tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___sortering', + {}, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.Select, + editorProps: { + options: GET_TIMELINE_SORT_ORDER_OPTIONS(), + }, + }, + }, + }, +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss new file mode 100644 index 00000000..eb4df4b2 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -0,0 +1,144 @@ +.c-block-timeline { + position: relative; + + &__list { + position: relative; + margin: 0; + padding: 0; + list-style: none; + + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0.5rem; + width: 1px; + background-color: currentcolor; + opacity: 0.3; + } + } + + &__node { + position: relative; + padding-left: 2rem; + + &:not(:last-child) { + margin-bottom: 1.5rem; + } + } + + &__node-marker { + position: absolute; + top: 0.6rem; + left: 0.125rem; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background-color: currentcolor; + } + + &__node-date { + display: flex; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 0.5rem; + } + + &__node-year { + font-size: 1.5rem; + font-weight: 700; + } + + &__node-full-date { + font-size: 0.875rem; + opacity: 0.7; + } + + &__node-content { + padding: 1rem; + } + + &__node-title { + margin: 0.5rem 0 0; + } + + &__node-description { + margin-top: 0.25rem; + } + + &__node-image-wrapper { + width: 100%; + } + + &__node-image { + display: block; + width: 100%; + height: 20rem; + object-fit: cover; + object-position: center; + } + + &__node-image-caption { + padding-top: 0.5rem; + } + + &__node-image-copyright { + margin: 0; + font-size: 0.75rem; + color: var(--c-block-timeline-copyright-color, #757575); + } + + &__node-image-description { + margin: 0.25rem 0 0; + font-weight: 700; + } + + &__node-object-media { + width: 100%; + } + + &__node-object-image { + display: block; + width: 100%; + height: 20rem; + object-fit: cover; + object-position: top; + } + + &__node-object-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background-color: #fff; + } + + &__node-object-title { + margin: 0; + font-weight: 700; + } + + &__node-object-footer { + display: flex; + align-items: center; + gap: 0.75rem; + } + + &__node-object-maintainer-logo { + height: 1.5rem; + width: auto; + } + + &__back-to-top { + display: block; + margin: 1.5rem 0 0 2rem; + background: none; + border: none; + padding: 0; + text-decoration: underline; + cursor: pointer; + font: inherit; + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx new file mode 100644 index 00000000..aa768455 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -0,0 +1,211 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import clsx from 'clsx'; +import type { FunctionComponent, ReactElement } from 'react'; +import React, { useMemo, useRef } from 'react'; +import { AdminConfigManager } from '~core/config/config.class'; +import { SanitizePreset } from '~shared/helpers/sanitize/presets'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; +import { Color } from '~modules/content-page/types/content-block.types'; +import type { + TimelineNodeBlockComponentState, + TimelineSortOrder, +} from '~modules/content-page/types/content-block.types'; +import { FlowPlayerWrapper } from '~shared/components/FlowPlayerWrapper/FlowPlayerWrapper'; +import { SmartLink } from '~shared/components/SmartLink/SmartLink'; +import Html from '~shared/components/Html/Html'; +import type { DefaultComponentProps } from '~shared/types/components'; +import { useGetTimelineIeObject } from './hooks/useGetTimelineIeObject'; + +import './BlockTimeline.scss'; + +export interface BlockTimelineProps extends DefaultComponentProps { + elements: TimelineNodeBlockComponentState[]; + sortOrder?: TimelineSortOrder; +} + +const getYear = (date: string): number => new Date(date).getFullYear(); + +const BlockTimelineObject: FunctionComponent<{ pid: string; fallbackTitle: string }> = ({ + pid, + fallbackTitle, +}) => { + const { data: ieObject, isLoading } = useGetTimelineIeObject(pid); + + if (isLoading || !ieObject) { + return null; + } + + return ( +
+
+ {ieObject.mediaType === 'video' || ieObject.mediaType === 'audio' ? ( + + ) : ( + ieObject.thumbnailUrl && ( + {ieObject.name + ) + )} +
+
+

{ieObject.name || fallbackTitle}

+
+ {ieObject.detailPageUrl && ( + + {tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-volledig-fragment', + {}, + [HET_ARCHIEF] + )} + + )} + {ieObject.maintainerLogo && ( + {ieObject.maintainerName + )} +
+
+
+ ); +}; + +export const BlockTimeline: FunctionComponent = ({ + className, + elements = [], + sortOrder = 'date__desc', +}): ReactElement => { + const containerRef = useRef(null); + const locale = AdminConfigManager.getConfig().locale; + + const sortedElements = useMemo(() => { + const sorted = [...elements].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); + return sortOrder === 'date__asc' ? sorted : sorted.reverse(); + }, [elements, sortOrder]); + + const formatDate = (date: string): string => + new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(date)); + + const scrollToTop = () => { + containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( +
+
    + {sortedElements.map((node, index) => { + const showYear = + index === 0 || getYear(node.date) !== getYear(sortedElements[index - 1].date); + const backgroundColor = + node.backgroundColor && node.backgroundColor !== Color.Transparent + ? node.backgroundColor + : undefined; + + return ( +
  1. +
  2. + ); + })} +
+ {sortedElements.length > 0 && ( + + )} +
+ ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts new file mode 100644 index 00000000..649169fc --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts @@ -0,0 +1,92 @@ +import { useQuery } from '@tanstack/react-query'; +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 { QUERY_KEYS } from '~shared/types'; + +export type TimelineIeObjectMediaType = 'video' | 'audio' | 'newspaper' | 'unknown'; + +export interface TimelineIeObjectData { + schemaIdentifier: string; + name: string; + description?: string; + thumbnailUrl?: string; + src?: string; + duration?: string; + mediaType: TimelineIeObjectMediaType; + maintainerName?: string; + maintainerLogo?: string; + detailPageUrl?: string; +} + + +interface RawIeObject { + schema_identifier?: string; + name?: string; + description?: string; + thumbnail_url?: string; + representations?: { browse_path?: string; dcterms_format?: string }[]; + duration?: string; + dcterms_format?: string; + maintainer_name?: string; + maintainer_logo?: string; + detail_page_url?: string; +} + +const getMediaType = (raw: RawIeObject): TimelineIeObjectMediaType => { + const format = (raw.dcterms_format || raw.representations?.[0]?.dcterms_format || '').toLowerCase(); + if (format.includes('video')) { + return 'video'; + } + if (format.includes('audio')) { + return 'audio'; + } + if (format.includes('newspaper') || format.includes('krant')) { + return 'newspaper'; + } + return 'unknown'; +}; + +const parseIeObject = (raw: RawIeObject): TimelineIeObjectData => ({ + schemaIdentifier: raw.schema_identifier || '', + name: raw.name || '', + description: raw.description, + thumbnailUrl: raw.thumbnail_url, + src: raw.representations?.[0]?.browse_path, + duration: raw.duration, + mediaType: getMediaType(raw), + maintainerName: raw.maintainer_name, + maintainerLogo: raw.maintainer_logo, + detailPageUrl: raw.detail_page_url, +}); + +// Reuses the same `/ie-objects` search endpoint the content picker uses (there is no +// dedicated single-object endpoint in this proxy), filtering on the exact identifier. +const fetchTimelineIeObject = async (pid: string): Promise => { + try { + const response: { items: RawIeObject[] } = await fetchWithLogoutJson( + `${getProxyUrl()}/ie-objects`, + { + method: 'POST', + body: JSON.stringify({ + filters: [{ field: 'schema_identifier', operator: 'contains', value: pid }], + size: 1, + page: 0, + }), + } + ); + const raw = response.items?.[0]; + return raw ? parseIeObject(raw) : null; + } catch (err) { + throw new CustomError('Failed to fetch ie-object for timeline node', err, { pid }); + } +}; + +export const useGetTimelineIeObject = (pid: string | undefined) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_IE_OBJECT, pid], + queryFn: () => fetchTimelineIeObject(pid as string), + enabled: !!pid, + staleTime: 3600000, + }); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/index.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/index.ts new file mode 100644 index 00000000..59d2d282 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/index.ts @@ -0,0 +1,2 @@ +export * from './BlockTimeline'; +export * from './BlockTimeline.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..bbffa2d8 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 @@ -37,6 +37,7 @@ import { } from '~content-blocks/BlockSpotlight'; import { TAGS_WITH_LINK_BLOCK_CONFIG } from '~content-blocks/BlockTagsWithLink'; import { THREE_CLICKABLE_TILES_BLOCK_CONFIG } from '~content-blocks/BlockThreeClickableTiles'; +import { TIMELINE_BLOCK_CONFIG } from '~content-blocks/BlockTimeline'; import { UITGEKLAARD_BLOCK_CONFIG } from '~content-blocks/BlockUitgeklaard'; import { MEDIA_PLAYER_BLOCK_CONFIG } from '~content-blocks/BlockVideo'; import { MEDIA_PLAYER_TITLE_TEXT_BUTTON_BLOCK_CONFIG } from '~content-blocks/BlockVideoTitleTextButton'; @@ -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.Timeline]: TIMELINE_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..f9d0b6a7 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 @@ -38,6 +38,7 @@ import { } from '~content-blocks/BlockSpotlight'; import { INITIAL_TAGS_WITH_LINK_COMPONENTS_STATE } from '~content-blocks/BlockTagsWithLink'; import { INITIAL_THREE_CLICKABLE_TILES_COMPONENTS_STATE } from '~content-blocks/BlockThreeClickableTiles'; +import { INITIAL_TIMELINE_COMPONENTS_STATE } from '~content-blocks/BlockTimeline'; import { INITIAL_UITGEKLAARD_BLOCK_STATE } from '~content-blocks/BlockUitgeklaard'; import { INITIAL_MEDIA_PLAYER_COMPONENTS_STATE } from '~content-blocks/BlockVideo'; import { INITIAL_MEDIA_PLAYER_TITLE_TEXT_BUTTON_COMPONENTS_STATE } from '~content-blocks/BlockVideoTitleTextButton'; @@ -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.Timeline]: INITIAL_TIMELINE_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..7b6369ad 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( + 'react-admin/modules/content-page/const/get-content-block-type-options___tijdslijn', + {}, + [HET_ARCHIEF] + ), + value: ContentBlockType.Timeline, + }, ]; // Only show the content blocks that the client enabled through the config object diff --git a/ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts b/ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts new file mode 100644 index 00000000..2483e192 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts @@ -0,0 +1,23 @@ +import type { SelectOption } from '@viaa/avo2-components'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; +import type { TimelineSortOrder } from '../types/content-block.types'; + +export const GET_TIMELINE_SORT_ORDER_OPTIONS: () => SelectOption[] = () => [ + { + label: tText( + 'react-admin/modules/content-page/const/get-timeline-sort-order-options___aflopend-nieuwste-eerst', + {}, + [HET_ARCHIEF] + ), + value: 'date__desc', + }, + { + label: tText( + 'react-admin/modules/content-page/const/get-timeline-sort-order-options___oplopend-oudste-eerst', + {}, + [HET_ARCHIEF] + ), + value: 'date__asc', + }, +]; 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..52fe9663 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', + Timeline = 'TIMELINE', } export enum ContentBlockEditor { @@ -432,7 +433,8 @@ export type RepeatedContentBlockComponentState = | MediaGridBlockComponentState | ImageInfo // project spotlight & spotlight | RichTextBlockComponentState - | ThreeClickableTilesBlockComponentState; + | ThreeClickableTilesBlockComponentState + | TimelineNodeBlockComponentState; export type SingleContentBlockComponentState = | HeadingBlockComponentState @@ -583,3 +585,25 @@ export interface HetArchiefIeObject { name: string; schemaIdentifier: string; } + +export type TimelineSortOrder = 'date__asc' | 'date__desc'; + +export type TimelineNodeVisualType = 'NONE' | 'OBJECT' | 'IMAGE'; + +export interface TimelineNodeBlockComponentState { + date: string; + title: string; + text?: string; + visualType: TimelineNodeVisualType; + mediaItem?: ButtonAction; // Content picker value pointing to an IE_OBJECT (pid/fragmentId) + image?: string; + imageAlt?: string; + imageCaptionCopyright?: string; + imageCaptionCopyrightIconVisible?: boolean; + imageCaptionDescription?: string; + backgroundColor?: Color; +} + +export interface TimelineBlockState extends DefaultContentBlockState { + sortOrder: TimelineSortOrder; +} diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index c9e68078..67437cef 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.Timeline, ], defaultPageWidth: ContentPageWidth.LARGE, onSaveContentPage: async (contentPageInfo: ContentPageInfo) => { diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index b72f7032..ba451fb9 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -933,6 +933,29 @@ "react-admin/modules/content-page/components/blocks/block-maintainers-grid/block-maintainers-grid___zichtbare-items": "Zichtbare items", "react-admin/modules/content-page/components/blocks/block-page-overview/block-page-overview___error": "Error", "react-admin/modules/content-page/components/blocks/block-spotlight/block-project-spotlight___pagina-niet-gevonden": "Pagina niet gevonden", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___achtergrondkleur-van-de-node": "Achtergrondkleur van de node", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___afbeelding": "Afbeelding", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___alternatieve-tekst-afbeelding": "Alternatieve tekst afbeelding", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-volledig-fragment": "Bekijk volledig fragment", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-beschrijving": "Bijschrift: beschrijving", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-copyright": "Bijschrift: copyright", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___copyright-icoon-toevoegen": "Copyright icoon toevoegen", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___datum": "Datum", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___datum-is-verplicht": "Datum is verplicht", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___een-afbeelding-is-verplicht": "Een afbeelding is verplicht", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___geen": "Geen", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___keuze-van-visuele-elementen-is-verplicht": "Keuze van visuele elementen is verplicht", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___node": "Node", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___object": "Object", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___object-audio-video-of-krant": "Object (audio, video of krant)", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___selecteren-van-een-object-is-verplicht": "Selecteren van een object is verplicht", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___sortering": "Sortering", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___terug-naar-boven": "Terug naar boven", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___tekst": "Tekst", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___tijdslijn": "Tijdslijn", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___titel": "Titel", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___titel-is-verplicht": "Titel is verplicht", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___visuele-elementen": "Visuele elementen", "react-admin/modules/content-page/components/blocks/block-video-title-text-button/block-video-title-text-button___toon-auteursrecht-melding-voor-deze-poster": "Toon auteursrecht melding voor deze poster", "react-admin/modules/content-page/components/blocks/block-video/block-video___bekijk-de-copyright-info-van-deze-afbeelding": "Bekijk de bronvermelding van deze afbeelding", "react-admin/modules/content-page/components/blocks/block-video/block-video___bron": "Bron >", @@ -948,8 +971,11 @@ "react-admin/modules/content-page/const/get-color-options___meemoo-logo": "Meemoo logo", "react-admin/modules/content-page/const/get-content-block-type-options___aanbieders-grid": "Aanbieders grid", "react-admin/modules/content-page/const/get-content-block-type-options___header-met-zoekveld": "Header met zoekveld", + "react-admin/modules/content-page/const/get-content-block-type-options___tijdslijn": "Tijdslijn", "react-admin/modules/content-page/const/get-image-grid-format-options___30-x-220": "30% x 220", "react-admin/modules/content-page/const/get-page-overview-item-style-options___accordion-twee-niveaus": "Accordion twee niveaus", + "react-admin/modules/content-page/const/get-timeline-sort-order-options___aflopend-nieuwste-eerst": "Aflopend (nieuwste eerst)", + "react-admin/modules/content-page/const/get-timeline-sort-order-options___oplopend-oudste-eerst": "Oplopend (oudste eerst)", "react-admin/modules/content-page/views/content-page-edit___bezig": "Bezig...", "react-admin/modules/content-page/views/content-page-edit___bezig-met-dupliceren-van-de-afbeeldingen": "Bezig met dupliceren van de afbeeldingen", "react-admin/modules/content-page/views/content-page-edit___de-content-pagina-info-is-overgenomen": "De content pagina info is overgenomen", From 19845e19769d64e89780e406fb16b8ee955415f2 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 20 Jul 2026 22:55:34 +0200 Subject: [PATCH 02/12] feat(ARC-3815): create initial version of timeline block https://meemoo.atlassian.net/browse/ARC-3815 --- .../content-page/const/get-content-block-type-options.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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 7b6369ad..3d5cede1 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 @@ -192,6 +192,14 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = ), value: ContentBlockType.Timeline, }, + { + label: tText( + 'react-admin/modules/content-page/const/get-content-block-type-options___tijdslijn', + {}, + [HET_ARCHIEF] + ), + value: ContentBlockType.Timeline, + }, ]; // Only show the content blocks that the client enabled through the config object From a00fdb67fd2bf36c6308131cd8e66778ed6f4c6e Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 13 Aug 2026 10:46:47 +0200 Subject: [PATCH 03/12] fix(ARC-3815): init date for timeline https://meemoo.atlassian.net/browse/ARC-3815 --- .../blocks/BlockTimeline/BlockTimeline.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index aa768455..2202811e 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -24,7 +24,10 @@ export interface BlockTimelineProps extends DefaultComponentProps { sortOrder?: TimelineSortOrder; } -const getYear = (date: string): number => new Date(date).getFullYear(); +const isValidDate = (date: string): boolean => !Number.isNaN(new Date(date).getTime()); + +const getYear = (date: string): number | null => + isValidDate(date) ? new Date(date).getFullYear() : null; const BlockTimelineObject: FunctionComponent<{ pid: string; fallbackTitle: string }> = ({ pid, @@ -106,11 +109,13 @@ export const BlockTimeline: FunctionComponent = ({ }, [elements, sortOrder]); const formatDate = (date: string): string => - new Intl.DateTimeFormat(locale, { - day: 'numeric', - month: 'long', - year: 'numeric', - }).format(new Date(date)); + isValidDate(date) + ? new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(new Date(date)) + : ''; const scrollToTop = () => { containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); From ab971ba160d541031bce30169391bf988d74b6c9 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 13 Aug 2026 15:04:31 +0200 Subject: [PATCH 04/12] feat(ARC-3815): remove sorting options, improve styling https://meemoo.atlassian.net/browse/ARC-3815 --- .../BlockTimeline.editorconfig.ts | 13 - .../blocks/BlockTimeline/BlockTimeline.scss | 265 +++++++++++++++--- .../blocks/BlockTimeline/BlockTimeline.tsx | 90 +++--- .../const/get-timeline-sort-order-options.ts | 23 -- .../content-page/types/content-block.types.ts | 6 +- ui/src/shared/translations/hetArchief/nl.json | 3 - 6 files changed, 283 insertions(+), 117 deletions(-) delete mode 100644 ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts index f5570e0b..3b0f7749 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts @@ -1,6 +1,5 @@ import type { CheckboxProps, SelectOption } from '@viaa/avo2-components'; import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; -import { GET_TIMELINE_SORT_ORDER_OPTIONS } from '~modules/content-page/const/get-timeline-sort-order-options'; import type { FileUploadProps } from '~shared/components/FileUpload/FileUpload'; import { tText } from '~shared/helpers/translation-functions'; import { HET_ARCHIEF } from '~shared/types'; @@ -66,7 +65,6 @@ export const INITIAL_TIMELINE_COMPONENTS_STATE = (): TimelineNodeBlockComponentS export const INITIAL_TIMELINE_BLOCK_STATE = (): TimelineBlockState => ({ ...BLOCK_STATE_DEFAULTS(), - sortOrder: 'date__desc', }); export const TIMELINE_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ @@ -246,17 +244,6 @@ export const TIMELINE_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ state: INITIAL_TIMELINE_BLOCK_STATE(), fields: { ...BLOCK_FIELD_DEFAULTS(), - sortOrder: { - label: tText( - 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___sortering', - {}, - [HET_ARCHIEF] - ), - editorType: ContentBlockEditor.Select, - editorProps: { - options: GET_TIMELINE_SORT_ORDER_OPTIONS(), - }, - }, }, }, }); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss index eb4df4b2..ef5c1638 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -1,3 +1,24 @@ +@use "../../../../shared/styles/settings/colors" as colors; +@use "../../../../shared/styles/settings/variables" as variables; + +// Horizontal space reserved for the year labels, which sit to the left of the timeline +// line from $g-bp3 on. Below that breakpoint the years sit above the date, right of the line. +$year-column-width: 17rem; +// Distance between the timeline line and the node date / content. +$line-content-gap: 1.5rem; +// The circle / rectangle markers: one per node (alternating), plus a fixed circle-rectangle-circle +// cluster at the very start and end of the timeline. +$marker-size: 0.4375rem; +$marker-rectangle-height: 0.3rem; +// Aspect ratio for the node's image / video / audio media. +$node-media-aspect-ratio: '1215 / 630'; +// Vertical space between every item on the timeline (start cap, nodes, end cap). +$item-gap: 2rem; +// Inner padding of a node that has a background colour. +$node-background-padding: 1rem; +// Gap between a node's media (image / object) and the start of its background colour band. +$node-background-top-gap: 5rem; + .c-block-timeline { position: relative; @@ -7,74 +28,181 @@ padding: 0; list-style: none; + // The timeline line runs continuously behind every marker, from the first shape of the + // start cap to the last shape of the end cap. &::before { content: ''; position: absolute; top: 0; bottom: 0; - left: 0.5rem; + left: 0; + transform: translateX(-50%); width: 1px; background-color: currentcolor; opacity: 0.3; } + + > li:not(:last-child) { + margin-bottom: $item-gap; + } } - &__node { + // The fixed circle/rectangle/circle cluster marking the start and end of the timeline. The + // shapes touch, with no gap between them. + &__cap { position: relative; - padding-left: 2rem; - - &:not(:last-child) { - margin-bottom: 1.5rem; - } + display: flex; + flex-direction: column; + align-items: stretch; + width: $marker-size; + margin-left: calc(#{$marker-size} / -2); } - &__node-marker { - position: absolute; - top: 0.6rem; - left: 0.125rem; - width: 0.75rem; - height: 0.75rem; - border-radius: 50%; + &__cap-shape { + flex-shrink: 0; + width: 100%; + height: $marker-size; background-color: currentcolor; + + &--circle { + border-radius: 50%; + } + + &--rectangle { + height: $marker-rectangle-height; + border-radius: 0; + } } - &__node-date { - display: flex; - align-items: baseline; - gap: 0.75rem; - margin-bottom: 0.5rem; + &__node { + display: grid; + grid-template-columns: 1fr; + grid-template-areas: + 'year' + 'date' + 'content'; } &__node-year { - font-size: 1.5rem; + grid-area: year; + align-self: baseline; + padding-left: $line-content-gap; + font-size: 2.25rem; font-weight: 700; + line-height: 1.2; } - &__node-full-date { - font-size: 0.875rem; - opacity: 0.7; + &__node-date { + grid-area: date; + align-self: baseline; + position: relative; + display: block; + padding-left: $line-content-gap; + font-family: "Sofia Pro", sans-serif; + font-size: 1.5rem; + font-weight: 800; + font-style: normal; + line-height: 26px; + letter-spacing: 0; + } + + // A node's marker alternates between a circle and a rectangle (see BlockTimeline.tsx). + &__node-marker { + position: absolute; + top: 50%; + left: 0; + transform: translate(-50%, -50%); + width: $marker-size; + height: $marker-size; + background-color: currentcolor; + + &--circle { + border-radius: 50%; + } + + &--rectangle { + height: $marker-rectangle-height; + border-radius: 0; + } } &__node-content { - padding: 1rem; + grid-area: content; + margin-top: 0.5rem; + padding-left: $line-content-gap; + + &--has-background { + position: relative; + padding-bottom: $node-background-padding; + + // Full-page-width colour band behind the node's content. The 5000px width is a + // generous fixed size that always covers the viewport, breaking out of any + // max-width content container (see BlockOverviewThemes for the same pattern). With + // media, it starts $node-background-top-gap below the media's top edge so that edge + // stays free of colour; without media there's nothing to leave uncovered, so it + // starts at the very top and covers the title and description in full. + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -2500px; + z-index: 0; + width: 5000px; + background-color: var(--c-block-timeline-node-bg); + } + + &.c-block-timeline__node-content--has-media::before { + top: $node-background-top-gap; + } + + > * { + position: relative; + z-index: 1; + } + + .c-block-timeline__node-text, + .c-block-timeline__node-image-caption { + padding-right: $node-background-padding; + padding-left: calc(#{$line-content-gap} + #{$node-background-padding}); + } + } + } + + &__node-text { + padding-top: 1rem; } &__node-title { - margin: 0.5rem 0 0; + margin: 0; + font-family: "Sofia Pro", sans-serif; + font-size: 1.5rem; + font-weight: 800; + font-style: normal; + line-height: 26px; + letter-spacing: 0; } &__node-description { margin-top: 0.25rem; + font-family: "Sofia Pro", sans-serif; + font-size: 1.125rem; + font-weight: 500; + font-style: normal; + line-height: 28px; + letter-spacing: 0; } &__node-image-wrapper { + position: relative; + z-index: 1; width: 100%; } &__node-image { display: block; width: 100%; - height: 20rem; + aspect-ratio: #{$node-media-aspect-ratio}; object-fit: cover; object-position: center; } @@ -85,23 +213,36 @@ &__node-image-copyright { margin: 0; - font-size: 0.75rem; + font-family: "Sofia Pro", sans-serif; + font-size: 0.875rem; + font-weight: 500; + font-style: normal; + line-height: 20px; + letter-spacing: 0; color: var(--c-block-timeline-copyright-color, #757575); } &__node-image-description { margin: 0.25rem 0 0; - font-weight: 700; + font-family: "Sofia Pro", sans-serif; + font-size: 0.875rem; + font-weight: 500; + font-style: normal; + line-height: 20px; + letter-spacing: 0; + color: colors.$color-gray-1000; } &__node-object-media { + position: relative; + z-index: 1; width: 100%; } &__node-object-image { display: block; width: 100%; - height: 20rem; + aspect-ratio: #{$node-media-aspect-ratio}; object-fit: cover; object-position: top; } @@ -112,7 +253,7 @@ justify-content: space-between; gap: 1rem; padding: 0.75rem 1rem; - background-color: #fff; + background-color: colors.$white; } &__node-object-title { @@ -132,13 +273,69 @@ } &__back-to-top { - display: block; - margin: 1.5rem 0 0 2rem; + display: flex; + align-items: center; + gap: 0.375rem; + width: fit-content; + margin: 1.5rem 0 0 auto; background: none; border: none; padding: 0; - text-decoration: underline; cursor: pointer; - font: inherit; + color: #757575; + font-family: "Sofia Pro", sans-serif; + font-size: 1rem; + font-weight: 500; + font-style: normal; + line-height: 20px; + letter-spacing: 0; + + &-icon { + width: 1rem; + height: 1rem; + } + } + + // From this breakpoint on, the year labels move to the left of the timeline line, and the + // image / object media bleed left over the line to align with the year. + @media (min-width: variables.$g-bp3) { + &__list::before { + left: $year-column-width; + } + + &__cap { + margin-left: calc(#{$year-column-width} - #{$marker-size} / 2); + } + + &__node { + grid-template-columns: $year-column-width 1fr; + grid-template-areas: + 'year date' + '. content'; + } + + &__node-year { + padding-right: 1rem; + padding-left: 0; + font-size: 2.5rem; + } + + &__node-image-wrapper, + &__node-object-media { + margin-left: calc(-1 * (#{$year-column-width} + #{$line-content-gap})); + width: calc(100% + #{$year-column-width} + #{$line-content-gap}); + } + + // The image bleeds left over the timeline line (see above), so its caption needs its own + // offset to line back up with the date, which sits to the right of the line. + &__node-image-caption { + padding-left: calc(#{$year-column-width} + #{$line-content-gap}); + } + + &__node-content--has-background &__node-image-caption { + padding-left: calc( + #{$year-column-width} + #{$line-content-gap} + #{$node-background-padding} + ); + } } } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index 2202811e..bc6b780d 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -1,19 +1,17 @@ import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import clsx from 'clsx'; -import type { FunctionComponent, ReactElement } from 'react'; -import React, { useMemo, useRef } from 'react'; +import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; +import React, { useRef } from 'react'; import { AdminConfigManager } from '~core/config/config.class'; -import { SanitizePreset } from '~shared/helpers/sanitize/presets'; -import { tText } from '~shared/helpers/translation-functions'; -import { HET_ARCHIEF } from '~shared/types'; +import type { TimelineNodeBlockComponentState } from '~modules/content-page/types/content-block.types'; import { Color } from '~modules/content-page/types/content-block.types'; -import type { - TimelineNodeBlockComponentState, - TimelineSortOrder, -} from '~modules/content-page/types/content-block.types'; import { FlowPlayerWrapper } from '~shared/components/FlowPlayerWrapper/FlowPlayerWrapper'; -import { SmartLink } from '~shared/components/SmartLink/SmartLink'; import Html from '~shared/components/Html/Html'; +import { Icon } from '~shared/components/Icon/Icon'; +import { SmartLink } from '~shared/components/SmartLink/SmartLink'; +import { SanitizePreset } from '~shared/helpers/sanitize/presets'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; import type { DefaultComponentProps } from '~shared/types/components'; import { useGetTimelineIeObject } from './hooks/useGetTimelineIeObject'; @@ -21,7 +19,6 @@ import './BlockTimeline.scss'; export interface BlockTimelineProps extends DefaultComponentProps { elements: TimelineNodeBlockComponentState[]; - sortOrder?: TimelineSortOrder; } const isValidDate = (date: string): boolean => !Number.isNaN(new Date(date).getTime()); @@ -29,6 +26,16 @@ const isValidDate = (date: string): boolean => !Number.isNaN(new Date(date).getT const getYear = (date: string): number | null => isValidDate(date) ? new Date(date).getFullYear() : null; +// The timeline starts and ends with the same fixed circle/rectangle/circle cluster of markers. +// Every node in between alternates circle, rectangle, circle, rectangle, ... based on its index. +const TimelineCap: FunctionComponent<{ position: 'start' | 'end' }> = ({ position }) => ( + +); + const BlockTimelineObject: FunctionComponent<{ pid: string; fallbackTitle: string }> = ({ pid, fallbackTitle, @@ -96,18 +103,10 @@ const BlockTimelineObject: FunctionComponent<{ pid: string; fallbackTitle: strin export const BlockTimeline: FunctionComponent = ({ className, elements = [], - sortOrder = 'date__desc', }): ReactElement => { const containerRef = useRef(null); const locale = AdminConfigManager.getConfig().locale; - const sortedElements = useMemo(() => { - const sorted = [...elements].sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() - ); - return sortOrder === 'date__asc' ? sorted : sorted.reverse(); - }, [elements, sortOrder]); - const formatDate = (date: string): string => isValidDate(date) ? new Intl.DateTimeFormat(locale, { @@ -124,31 +123,46 @@ export const BlockTimeline: FunctionComponent = ({ return (
    - {sortedElements.map((node, index) => { - const showYear = - index === 0 || getYear(node.date) !== getYear(sortedElements[index - 1].date); + + {elements.map((node, index) => { + const showYear = index === 0 || getYear(node.date) !== getYear(elements[index - 1].date); const backgroundColor = node.backgroundColor && node.backgroundColor !== Color.Transparent ? node.backgroundColor : undefined; + const markerShape = index % 2 === 0 ? 'circle' : 'rectangle'; + const hasMedia = + (node.visualType === 'OBJECT' && !!node.mediaItem?.value) || + (node.visualType === 'IMAGE' && !!node.image); return (
  1. -
  2. ); })} +
- {sortedElements.length > 0 && ( - )}
diff --git a/ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts b/ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts deleted file mode 100644 index 2483e192..00000000 --- a/ui/src/react-admin/modules/content-page/const/get-timeline-sort-order-options.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { SelectOption } from '@viaa/avo2-components'; -import { tText } from '~shared/helpers/translation-functions'; -import { HET_ARCHIEF } from '~shared/types'; -import type { TimelineSortOrder } from '../types/content-block.types'; - -export const GET_TIMELINE_SORT_ORDER_OPTIONS: () => SelectOption[] = () => [ - { - label: tText( - 'react-admin/modules/content-page/const/get-timeline-sort-order-options___aflopend-nieuwste-eerst', - {}, - [HET_ARCHIEF] - ), - value: 'date__desc', - }, - { - label: tText( - 'react-admin/modules/content-page/const/get-timeline-sort-order-options___oplopend-oudste-eerst', - {}, - [HET_ARCHIEF] - ), - value: 'date__asc', - }, -]; 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 ea63130f..de7beff3 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 @@ -609,8 +609,6 @@ export interface HetArchiefIeObject { schemaIdentifier: string; } -export type TimelineSortOrder = 'date__asc' | 'date__desc'; - export type TimelineNodeVisualType = 'NONE' | 'OBJECT' | 'IMAGE'; export interface TimelineNodeBlockComponentState { @@ -627,6 +625,4 @@ export interface TimelineNodeBlockComponentState { backgroundColor?: Color; } -export interface TimelineBlockState extends DefaultContentBlockState { - sortOrder: TimelineSortOrder; -} +export type TimelineBlockState = DefaultContentBlockState; diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index a3e7638e..52b808d3 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -1025,7 +1025,6 @@ "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___object": "Object", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___object-audio-video-of-krant": "Object (audio, video of krant)", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___selecteren-van-een-object-is-verplicht": "Selecteren van een object is verplicht", - "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___sortering": "Sortering", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___terug-naar-boven": "Terug naar boven", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___tekst": "Tekst", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___tijdslijn": "Tijdslijn", @@ -1050,8 +1049,6 @@ "react-admin/modules/content-page/const/get-content-block-type-options___tijdslijn": "Tijdslijn", "react-admin/modules/content-page/const/get-image-grid-format-options___30-x-220": "30% x 220", "react-admin/modules/content-page/const/get-page-overview-item-style-options___accordion-twee-niveaus": "Accordion twee niveaus", - "react-admin/modules/content-page/const/get-timeline-sort-order-options___aflopend-nieuwste-eerst": "Aflopend (nieuwste eerst)", - "react-admin/modules/content-page/const/get-timeline-sort-order-options___oplopend-oudste-eerst": "Oplopend (oudste eerst)", "react-admin/modules/content-page/views/content-page-edit___bezig": "Bezig...", "react-admin/modules/content-page/views/content-page-edit___bezig-met-dupliceren-van-de-afbeeldingen": "Bezig met dupliceren van de afbeeldingen", "react-admin/modules/content-page/views/content-page-edit___de-content-pagina-info-is-overgenomen": "De content pagina info is overgenomen", From fcf8ba753df5426a7fc875d011d050f2f8e63075 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 13 Aug 2026 15:56:55 +0200 Subject: [PATCH 05/12] feat(ARC-3815): enhance timeline block styling and player configuration https://meemoo.atlassian.net/browse/ARC-3815 Adjusts spacing, padding, and text colors within the `BlockTimeline` component for improved visual appearance and readability. Repositions image captions for better structural hierarchy. Introduces `AudioOrVideoPlayerProps` and registers a placeholder player in `AdminConfig` to facilitate media integration within the timeline for Het Archief. Updates button configurations in the local test app to align with Het Archief button styles. --- .../react-admin/core/config/config.types.ts | 4 +- .../blocks/BlockAvoHero/BlockAvoHero.tsx | 2 +- .../blocks/BlockTimeline/BlockTimeline.scss | 39 ++--- .../blocks/BlockTimeline/BlockTimeline.tsx | 34 ++-- .../AudioOrVideoPlayer.types.ts | 61 ++++++++ ui/src/shared/helpers/admin-core-config.tsx | 145 +++++++++--------- 6 files changed, 169 insertions(+), 116 deletions(-) create mode 100644 ui/src/react-admin/modules/shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types.ts diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index b1d97eaa..970fd03c 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -14,6 +14,7 @@ import type { import type { App, Locale } from '~modules/translations/translations.core.types'; import type { UserBulkAction } from '~modules/user/user.types'; +import type { AudioOrVideoPlayerProps } from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types.ts'; import type { FlowPlayerWrapperProps } from '~shared/components/FlowPlayerWrapper/FlowPlayerWrapper.types'; export enum ToastType { @@ -94,7 +95,8 @@ export interface AdminConfig { component: ComponentType | null; }; defaultAudioStill: string; - flowplayer?: FC; + flowplayer?: FC; // User by avo for + audioOrVideoPlayer?: FC; // Used by hetarchief for timeline component buttonTypes: () => { label: string; value: string }[]; enableMultiLanguage: boolean; }; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx index bcde1bed..1d8273a3 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx @@ -89,7 +89,7 @@ export const BlockAvoHero: FunctionComponent = ({ )} {!!textBelowButtons && ( - {isString(textBelowButtons) && } + {isString(textBelowButtons) && } {!isString(textBelowButtons) && textBelowButtons} )} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss index ef5c1638..aaca0308 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -5,7 +5,7 @@ // line from $g-bp3 on. Below that breakpoint the years sit above the date, right of the line. $year-column-width: 17rem; // Distance between the timeline line and the node date / content. -$line-content-gap: 1.5rem; +$line-content-gap: 3rem; // The circle / rectangle markers: one per node (alternating), plus a fixed circle-rectangle-circle // cluster at the very start and end of the timeline. $marker-size: 0.4375rem; @@ -13,9 +13,9 @@ $marker-rectangle-height: 0.3rem; // Aspect ratio for the node's image / video / audio media. $node-media-aspect-ratio: '1215 / 630'; // Vertical space between every item on the timeline (start cap, nodes, end cap). -$item-gap: 2rem; +$item-gap: 12rem; // Inner padding of a node that has a background colour. -$node-background-padding: 1rem; +$node-background-padding: 4rem; // Gap between a node's media (image / object) and the start of its background colour band. $node-background-top-gap: 5rem; @@ -40,11 +40,16 @@ $node-background-top-gap: 5rem; width: 1px; background-color: currentcolor; opacity: 0.3; + z-index: 1; } - > li:not(:last-child) { + > li:not(:first-child):not(:last-child) { margin-bottom: $item-gap; } + + > li:first-child { + margin-bottom: 6rem; + } } // The fixed circle/rectangle/circle cluster marking the start and end of the timeline. The @@ -104,6 +109,7 @@ $node-background-top-gap: 5rem; font-style: normal; line-height: 26px; letter-spacing: 0; + color: colors.$color-gray-300; } // A node's marker alternates between a circle and a rectangle (see BlockTimeline.tsx). @@ -160,12 +166,6 @@ $node-background-top-gap: 5rem; position: relative; z-index: 1; } - - .c-block-timeline__node-text, - .c-block-timeline__node-image-caption { - padding-right: $node-background-padding; - padding-left: calc(#{$line-content-gap} + #{$node-background-padding}); - } } } @@ -186,11 +186,12 @@ $node-background-top-gap: 5rem; &__node-description { margin-top: 0.25rem; font-family: "Sofia Pro", sans-serif; - font-size: 1.125rem; font-weight: 500; font-style: normal; line-height: 28px; letter-spacing: 0; + font-size: 18px; + } &__node-image-wrapper { @@ -219,7 +220,7 @@ $node-background-top-gap: 5rem; font-style: normal; line-height: 20px; letter-spacing: 0; - color: var(--c-block-timeline-copyright-color, #757575); + color: var(--c-block-timeline-copyright-color, colors.$color-gray-300); } &__node-image-description { @@ -282,7 +283,7 @@ $node-background-top-gap: 5rem; border: none; padding: 0; cursor: pointer; - color: #757575; + color: colors.$color-gray-300; font-family: "Sofia Pro", sans-serif; font-size: 1rem; font-weight: 500; @@ -325,17 +326,5 @@ $node-background-top-gap: 5rem; margin-left: calc(-1 * (#{$year-column-width} + #{$line-content-gap})); width: calc(100% + #{$year-column-width} + #{$line-content-gap}); } - - // The image bleeds left over the timeline line (see above), so its caption needs its own - // offset to line back up with the date, which sits to the right of the line. - &__node-image-caption { - padding-left: calc(#{$year-column-width} + #{$line-content-gap}); - } - - &__node-content--has-background &__node-image-caption { - padding-left: calc( - #{$year-column-width} + #{$line-content-gap} + #{$node-background-padding} - ); - } } } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index bc6b780d..548efeac 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -177,26 +177,26 @@ export const BlockTimeline: FunctionComponent = ({ alt={node.imageAlt || node.title} className="c-block-timeline__node-image" /> - {(node.imageCaptionCopyright || node.imageCaptionDescription) && ( -
- {node.imageCaptionCopyright && ( -

- {node.imageCaptionCopyrightIconVisible !== false && ( - - )} - {node.imageCaptionCopyright} -

- )} - {node.imageCaptionDescription && ( -

- {node.imageCaptionDescription} -

- )} -
- )} )}
+ {(node.imageCaptionCopyright || node.imageCaptionDescription) && ( +
+ {node.imageCaptionCopyright && ( +

+ {node.imageCaptionCopyrightIconVisible !== false && ( + + )} + {node.imageCaptionCopyright} +

+ )} + {node.imageCaptionDescription && ( +

+ {node.imageCaptionDescription} +

+ )} +
+ )}

{node.title}

{node.text && ( void; + onPause: () => void; + onMediaReady: (isAvailable: boolean, playableFile: IeObjectFile | null) => void; + onMediaDurationLoaded?: (duration: number) => void; + dctermsFormat: IeObjectType | null; + schemaIdentifier: string | undefined; + representation: IeObjectRepresentation | null | undefined; + maintainerLogo: string | null | undefined; + cuePoints: CuePoints | undefined; + locationId: string; + poster: string | undefined; +}; diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index b69d2642..bcf3e80f 100644 --- a/ui/src/shared/helpers/admin-core-config.tsx +++ b/ui/src/shared/helpers/admin-core-config.tsx @@ -182,96 +182,97 @@ export function getAdminCoreConfigForLocalTestApp(navigateFunc: NavigateFunction }, defaultAudioStill: 'FAKE_DEFAULT_AUDIO_STILL', enableMultiLanguage: true, + audioOrVideoPlayer: () => ( +
AudioOrVideoPlayer
+ ), buttonTypes: () => [ // Het archief buttons - // { - // label: tText('index___zilver'), - // value: 'content-page-button--silver', - // }, - // { - // label: tText( - // 'index___blauw-groen' - // ), - // value: 'content-page-button--teal', - // }, - // { - // label: tText('index___wit'), - // value: 'content-page-button--white', - // }, - // { - // label: tText('index___zwart'), - // value: 'content-page-button--black', - // }, - // { - // label: tText('index___outline'), - // value: 'content-page-button--outline', - // }, - // { - // label: tText('index___tekst'), - // value: 'content-page-button--text', - // }, - // { - // label: tText('index___rood'), - // value: 'content-page-button--red', - // }, - // { - // label: tText('index___link'), - // value: 'content-page-button--link', - // }, - - // Avo buttons - { - label: tText('admin/content-block/content-block___primair'), - value: 'primary', - }, - { - label: tText('admin/content-block/content-block___secundair'), - value: 'secondary', - }, - { - label: tText('admin/content-block/content-block___secundair-invers'), - value: 'secondary-i', - }, - { - label: tText('admin/content-block/content-block___tertiair'), - value: 'tertiary', - }, - { - label: tText('admin/content-block/content-block___randloos'), - value: 'borderless', - }, { - label: tText('admin/content-block/content-block___randloos-invers'), - value: 'borderless-i', + label: tText('index___zilver'), + value: 'content-page-button--silver', }, { - label: tText('admin/content-block/content-block___gevaar'), - value: 'danger', + label: tText('index___blauw-groen'), + value: 'content-page-button--teal', }, { - label: tText('admin/content-block/content-block___gevaar-hover'), - value: 'danger-hover', + label: tText('index___wit'), + value: 'content-page-button--white', }, { - label: tText('admin/content-block/content-block___link'), - value: 'link', + label: tText('index___zwart'), + value: 'content-page-button--black', }, { - label: tText('admin/content-block/content-block___link-inline'), - value: 'inline-link', + label: tText('index___outline'), + value: 'content-page-button--outline', }, { - label: tText('admin/content-block/content-block___leerling-primair-geel'), - value: 'pupil-primary', + label: tText('index___tekst'), + value: 'content-page-button--text', }, { - label: tText('admin/content-block/content-block___leerling-link-tekst-in-geel'), - value: 'pupil-link', + label: tText('index___rood'), + value: 'content-page-button--red', }, { - label: tText('admin/content-block/content-block___leerling-link-geel-inline'), - value: 'pupil-inline-link', + label: tText('index___link'), + value: 'content-page-button--link', }, + + // Avo buttons + // { + // label: tText('admin/content-block/content-block___primair'), + // value: 'primary', + // }, + // { + // label: tText('admin/content-block/content-block___secundair'), + // value: 'secondary', + // }, + // { + // label: tText('admin/content-block/content-block___secundair-invers'), + // value: 'secondary-i', + // }, + // { + // label: tText('admin/content-block/content-block___tertiair'), + // value: 'tertiary', + // }, + // { + // label: tText('admin/content-block/content-block___randloos'), + // value: 'borderless', + // }, + // { + // label: tText('admin/content-block/content-block___randloos-invers'), + // value: 'borderless-i', + // }, + // { + // label: tText('admin/content-block/content-block___gevaar'), + // value: 'danger', + // }, + // { + // label: tText('admin/content-block/content-block___gevaar-hover'), + // value: 'danger-hover', + // }, + // { + // label: tText('admin/content-block/content-block___link'), + // value: 'link', + // }, + // { + // label: tText('admin/content-block/content-block___link-inline'), + // value: 'inline-link', + // }, + // { + // label: tText('admin/content-block/content-block___leerling-primair-geel'), + // value: 'pupil-primary', + // }, + // { + // label: tText('admin/content-block/content-block___leerling-link-tekst-in-geel'), + // value: 'pupil-link', + // }, + // { + // label: tText('admin/content-block/content-block___leerling-link-geel-inline'), + // value: 'pupil-inline-link', + // }, ], }, content_blocks: { From d934f872224f867b8440de70dc553307b45c0ba0 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 13 Aug 2026 16:19:55 +0200 Subject: [PATCH 06/12] feat(ARC-3815): refactor timeline block for batched IE object fetching and generic player Optimizes performance by fetching all timeline IE objects in a single batched request. Integrates the generic `AudioOrVideoPlayer` component for media playback, replacing the `FlowPlayerWrapper`. Includes minor styling updates for the year display and UI improvements for the editor's content picker. https://meemoo.atlassian.net/browse/ARC-3815 --- .../BlockTimeline.editorconfig.ts | 2 + .../blocks/BlockTimeline/BlockTimeline.scss | 12 +- .../blocks/BlockTimeline/BlockTimeline.tsx | 117 ++++++++++++------ .../hooks/useGetTimelineIeObject.ts | 92 -------------- .../hooks/useGetTimelineIeObjects.ts | 60 +++++++++ 5 files changed, 149 insertions(+), 134 deletions(-) delete mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts index 3b0f7749..aa15e0a7 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts @@ -155,6 +155,8 @@ export const TIMELINE_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ editorProps: { allowedTypes: ['IE_OBJECT'], hideTargetSwitch: true, + // Only one type is allowed, so the type dropdown would only ever have a single option + hideTypeDropdown: true, }, isVisible: visualTypeIsObject, validator: (value: unknown) => diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss index aaca0308..ca82f951 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -92,9 +92,9 @@ $node-background-top-gap: 5rem; grid-area: year; align-self: baseline; padding-left: $line-content-gap; - font-size: 2.25rem; - font-weight: 700; - line-height: 1.2; + font-size: 4.8rem; + font-weight: 800; + line-height: 5.6rem; } &__node-date { @@ -248,6 +248,12 @@ $node-background-top-gap: 5rem; object-position: top; } + // The player renders its own aspect ratio (video) or fixed height (audio), it only needs the full width + &__node-object-player { + display: block; + width: 100%; + } + &__node-object-meta { display: flex; align-items: center; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index 548efeac..b01f978c 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -1,11 +1,12 @@ import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import clsx from 'clsx'; +import { compact, uniq } from 'es-toolkit/compat'; import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { AdminConfigManager } from '~core/config/config.class'; import type { TimelineNodeBlockComponentState } from '~modules/content-page/types/content-block.types'; import { Color } from '~modules/content-page/types/content-block.types'; -import { FlowPlayerWrapper } from '~shared/components/FlowPlayerWrapper/FlowPlayerWrapper'; +import { IeObjectType } from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types'; import Html from '~shared/components/Html/Html'; import { Icon } from '~shared/components/Icon/Icon'; import { SmartLink } from '~shared/components/SmartLink/SmartLink'; @@ -13,7 +14,8 @@ import { SanitizePreset } from '~shared/helpers/sanitize/presets'; import { tText } from '~shared/helpers/translation-functions'; import { HET_ARCHIEF } from '~shared/types'; import type { DefaultComponentProps } from '~shared/types/components'; -import { useGetTimelineIeObject } from './hooks/useGetTimelineIeObject'; +import type { TimelineIeObject } from './hooks/useGetTimelineIeObjects'; +import { useGetTimelineIeObjects } from './hooks/useGetTimelineIeObjects'; import './BlockTimeline.scss'; @@ -36,28 +38,50 @@ const TimelineCap: FunctionComponent<{ position: 'start' | 'end' }> = ({ positio ); -const BlockTimelineObject: FunctionComponent<{ pid: string; fallbackTitle: string }> = ({ - pid, - fallbackTitle, -}) => { - const { data: ieObject, isLoading } = useGetTimelineIeObject(pid); +// Formats that the AudioOrVideoPlayer can play. Other formats (newspaper, image) fall back to the thumbnail. +const PLAYABLE_FORMATS: IeObjectType[] = [ + IeObjectType.VIDEO, + IeObjectType.VIDEO_FRAGMENT, + IeObjectType.AUDIO, + IeObjectType.AUDIO_FRAGMENT, + IeObjectType.FILM, +]; - if (isLoading || !ieObject) { - return null; - } +const BlockTimelineObject: FunctionComponent<{ + ieObject: TimelineIeObject; + fallbackTitle: string; +}> = ({ ieObject, fallbackTitle }) => { + const [isPaused, setIsPaused] = useState(true); + const AudioOrVideoPlayer = AdminConfigManager.getConfig().components?.audioOrVideoPlayer; + + // The player only needs the first representation that contains a file, same as the object detail page + const representation = (ieObject.pages || []) + .flatMap((page) => page?.representations || []) + .find((rep) => !!rep?.files?.length); + + const isPlayable = + !!AudioOrVideoPlayer && + !!representation && + !!ieObject.dctermsFormat && + PLAYABLE_FORMATS.includes(ieObject.dctermsFormat); return (
- {ieObject.mediaType === 'video' || ieObject.mediaType === 'audio' ? ( - setIsPaused(false)} + onPause={() => setIsPaused(true)} + onMediaReady={() => undefined} /> ) : ( ieObject.thumbnailUrl && ( @@ -72,21 +96,19 @@ const BlockTimelineObject: FunctionComponent<{ pid: string; fallbackTitle: strin

{ieObject.name || fallbackTitle}

- {ieObject.detailPageUrl && ( - - {tText( - 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-volledig-fragment', - {}, - [HET_ARCHIEF] - )} - - )} + + {tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-volledig-fragment', + {}, + [HET_ARCHIEF] + )} + {ieObject.maintainerLogo && ( = ({ const containerRef = useRef(null); const locale = AdminConfigManager.getConfig().locale; + // Resolve all objects of the timeline in a single request + const pids = useMemo( + () => + uniq( + compact( + elements.map((node) => + node.visualType === 'OBJECT' && node.mediaItem?.value + ? String(node.mediaItem.value) + : null + ) + ) + ), + [elements] + ); + const { data: ieObjectsByPid } = useGetTimelineIeObjects(pids); + const formatDate = (date: string): string => isValidDate(date) ? new Intl.DateTimeFormat(locale, { @@ -131,6 +169,10 @@ export const BlockTimeline: FunctionComponent = ({ ? node.backgroundColor : undefined; const markerShape = index % 2 === 0 ? 'circle' : 'rectangle'; + const ieObject = + node.visualType === 'OBJECT' && node.mediaItem?.value + ? ieObjectsByPid?.[String(node.mediaItem.value)] + : undefined; const hasMedia = (node.visualType === 'OBJECT' && !!node.mediaItem?.value) || (node.visualType === 'IMAGE' && !!node.image); @@ -164,11 +206,8 @@ export const BlockTimeline: FunctionComponent = ({ : undefined } > - {node.visualType === 'OBJECT' && node.mediaItem?.value && ( - + {node.visualType === 'OBJECT' && ieObject && ( + )} {node.visualType === 'IMAGE' && node.image && (
diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts deleted file mode 100644 index 649169fc..00000000 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObject.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -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 { QUERY_KEYS } from '~shared/types'; - -export type TimelineIeObjectMediaType = 'video' | 'audio' | 'newspaper' | 'unknown'; - -export interface TimelineIeObjectData { - schemaIdentifier: string; - name: string; - description?: string; - thumbnailUrl?: string; - src?: string; - duration?: string; - mediaType: TimelineIeObjectMediaType; - maintainerName?: string; - maintainerLogo?: string; - detailPageUrl?: string; -} - - -interface RawIeObject { - schema_identifier?: string; - name?: string; - description?: string; - thumbnail_url?: string; - representations?: { browse_path?: string; dcterms_format?: string }[]; - duration?: string; - dcterms_format?: string; - maintainer_name?: string; - maintainer_logo?: string; - detail_page_url?: string; -} - -const getMediaType = (raw: RawIeObject): TimelineIeObjectMediaType => { - const format = (raw.dcterms_format || raw.representations?.[0]?.dcterms_format || '').toLowerCase(); - if (format.includes('video')) { - return 'video'; - } - if (format.includes('audio')) { - return 'audio'; - } - if (format.includes('newspaper') || format.includes('krant')) { - return 'newspaper'; - } - return 'unknown'; -}; - -const parseIeObject = (raw: RawIeObject): TimelineIeObjectData => ({ - schemaIdentifier: raw.schema_identifier || '', - name: raw.name || '', - description: raw.description, - thumbnailUrl: raw.thumbnail_url, - src: raw.representations?.[0]?.browse_path, - duration: raw.duration, - mediaType: getMediaType(raw), - maintainerName: raw.maintainer_name, - maintainerLogo: raw.maintainer_logo, - detailPageUrl: raw.detail_page_url, -}); - -// Reuses the same `/ie-objects` search endpoint the content picker uses (there is no -// dedicated single-object endpoint in this proxy), filtering on the exact identifier. -const fetchTimelineIeObject = async (pid: string): Promise => { - try { - const response: { items: RawIeObject[] } = await fetchWithLogoutJson( - `${getProxyUrl()}/ie-objects`, - { - method: 'POST', - body: JSON.stringify({ - filters: [{ field: 'schema_identifier', operator: 'contains', value: pid }], - size: 1, - page: 0, - }), - } - ); - const raw = response.items?.[0]; - return raw ? parseIeObject(raw) : null; - } catch (err) { - throw new CustomError('Failed to fetch ie-object for timeline node', err, { pid }); - } -}; - -export const useGetTimelineIeObject = (pid: string | undefined) => { - return useQuery({ - queryKey: [QUERY_KEYS.GET_IE_OBJECT, pid], - queryFn: () => fetchTimelineIeObject(pid as string), - enabled: !!pid, - staleTime: 3600000, - }); -}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts new file mode 100644 index 00000000..9adc7c0e --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query'; +import { compact, keyBy } from 'es-toolkit/compat'; +import { stringifyUrl } from 'query-string'; +import type { + IeObjectRepresentation, + IeObjectType, +} from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types'; +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 { QUERY_KEYS } from '~shared/types'; + +/** + * The subset of the ie-object we need to render a timeline node. + * The proxy returns camel case properties for this endpoint. + */ +export interface TimelineIeObject { + schemaIdentifier: string; + name: string; + thumbnailUrl?: string; + dctermsFormat: IeObjectType | null; + duration?: string; + maintainerName?: string; + maintainerLogo?: string | null; + maintainerOverlay?: boolean | null; + pages?: { representations: IeObjectRepresentation[] }[]; +} + +/** + * Resolves the ie-objects for all timeline nodes with visualType OBJECT in a single request. + * Same endpoint the hetarchief client uses: IeObjectsService.getBySchemaIdentifiers() + * @param pids the schema identifiers of the ie-objects. eg: qsqn5z7j6q + */ +const fetchTimelineIeObjects = async ( + pids: string[] +): Promise> => { + try { + const ieObjects = await fetchWithLogoutJson<(TimelineIeObject | null)[]>( + stringifyUrl({ + url: `${getProxyUrl()}/ie-objects`, + query: { + schemaIdentifiers: pids, + resolveThumbnailUrl: true, + }, + }) + ); + return keyBy(compact(ieObjects), (ieObject) => ieObject.schemaIdentifier); + } catch (err) { + throw new CustomError('Failed to fetch ie-objects for the timeline block', err, { pids }); + } +}; + +export const useGetTimelineIeObjects = (pids: string[]) => { + return useQuery>({ + queryKey: [QUERY_KEYS.GET_IE_OBJECT, ...pids], + queryFn: () => fetchTimelineIeObjects(pids), + enabled: pids.length > 0, + staleTime: 60 * 60 * 1000, // 1 hour + }); +}; From 015bced8fc8c529c4199f7568b5126df38b6c669 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 13 Aug 2026 17:06:07 +0200 Subject: [PATCH 07/12] feat(ARC-3815): refactor timeline object rendering and styling Separates the media display and metadata/CTA logic for timeline objects into dedicated components (`BlockTimelineObject` and `BlockTimelineObjectMeta`). This improves modularity and maintainability of the `BlockTimeline`. Updates styling for responsive year column widths and refines the object metadata layout for a clearer visual presentation. https://meemoo.atlassian.net/browse/ARC-3815 --- .../blocks/BlockTimeline/BlockTimeline.scss | 89 ++++++++++++----- .../blocks/BlockTimeline/BlockTimeline.tsx | 95 ++----------------- .../BlockTimeline/BlockTimelineObject.tsx | 65 +++++++++++++ .../BlockTimeline/BlockTimelineObjectMeta.tsx | 44 +++++++++ ui/src/shared/helpers/admin-core-config.tsx | 12 ++- 5 files changed, 193 insertions(+), 112 deletions(-) create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx create mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss index ca82f951..8adc77c4 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -1,9 +1,11 @@ @use "../../../../shared/styles/settings/colors" as colors; @use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/mixins/typography" as typography; // Horizontal space reserved for the year labels, which sit to the left of the timeline // line from $g-bp3 on. Below that breakpoint the years sit above the date, right of the line. -$year-column-width: 17rem; +$year-column-width-desktop: 17rem; +$year-column-width-mobile: 4rem; // Distance between the timeline line and the node date / content. $line-content-gap: 3rem; // The circle / rectangle markers: one per node (alternating), plus a fixed circle-rectangle-circle @@ -174,7 +176,7 @@ $node-background-top-gap: 5rem; } &__node-title { - margin: 0; + margin: variables.$g-spacer-unit * 4 0 0; font-family: "Sofia Pro", sans-serif; font-size: 1.5rem; font-weight: 800; @@ -215,21 +217,21 @@ $node-background-top-gap: 5rem; &__node-image-copyright { margin: 0; font-family: "Sofia Pro", sans-serif; - font-size: 0.875rem; + font-size: 10px; font-weight: 500; font-style: normal; - line-height: 20px; + line-height: 15px; letter-spacing: 0; color: var(--c-block-timeline-copyright-color, colors.$color-gray-300); } &__node-image-description { - margin: 0.25rem 0 0; + margin: 0.25rem 0 variables.$g-spacer-unit * 4; font-family: "Sofia Pro", sans-serif; - font-size: 0.875rem; + font-size: 10px; font-weight: 500; font-style: normal; - line-height: 20px; + line-height: 15px; letter-spacing: 0; color: colors.$color-gray-1000; } @@ -260,23 +262,41 @@ $node-background-top-gap: 5rem; justify-content: space-between; gap: 1rem; padding: 0.75rem 1rem; - background-color: colors.$white; + background-color: colors.$color-platinum; + height: 6rem; + position: absolute; + bottom: -6rem; + left: 0; + max-width: 100%; + } + + &__node-object-cta { + flex: 0 0 auto; + white-space: nowrap; } - &__node-object-title { + &__node-object-text { + flex: 1 1 auto; + min-width: 0; + } + + &__node-object-title, + &__node-object-maintainer-name { margin: 0; font-weight: 700; + display: block; + @include typography.truncate; } - &__node-object-footer { - display: flex; - align-items: center; - gap: 0.75rem; + &__node-object-maintainer-name { + font-weight: 500; } &__node-object-maintainer-logo { - height: 1.5rem; + flex: 0 0 auto; + height: 2.4rem; width: auto; + margin: 0 variables.$g-spacer-unit * 1.5 0 variables.$g-spacer-unit * 3; } &__back-to-top { @@ -303,19 +323,44 @@ $node-background-top-gap: 5rem; } } - // From this breakpoint on, the year labels move to the left of the timeline line, and the - // image / object media bleed left over the line to align with the year. - @media (min-width: variables.$g-bp3) { + &__list::before { + left: $year-column-width-desktop; + } + + &__cap { + margin-left: calc(#{$year-column-width-desktop} - #{$marker-size} / 2); + } + + &__node { + grid-template-columns: $year-column-width-desktop 1fr; + grid-template-areas: + 'year date' + '. content'; + } + + &__node-year { + padding-right: 1rem; + padding-left: 0; + font-size: 2.5rem; + } + + &__node-image-wrapper, + &__node-object-media { + margin-left: calc(-1 * (#{$year-column-width-desktop} + #{$line-content-gap})); + width: calc(100% + #{$year-column-width-desktop} + #{$line-content-gap}); + } + + @media (max-width: variables.$g-bp3) { &__list::before { - left: $year-column-width; + left: $year-column-width-mobile; } &__cap { - margin-left: calc(#{$year-column-width} - #{$marker-size} / 2); + margin-left: calc(#{$year-column-width-mobile} - #{$marker-size} / 2); } &__node { - grid-template-columns: $year-column-width 1fr; + grid-template-columns: $year-column-width-mobile 1fr; grid-template-areas: 'year date' '. content'; @@ -329,8 +374,8 @@ $node-background-top-gap: 5rem; &__node-image-wrapper, &__node-object-media { - margin-left: calc(-1 * (#{$year-column-width} + #{$line-content-gap})); - width: calc(100% + #{$year-column-width} + #{$line-content-gap}); + margin-left: calc(-1 * (#{$year-column-width-mobile} + #{$line-content-gap})); + width: calc(100% + #{$year-column-width-mobile} + #{$line-content-gap}); } } } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index b01f978c..347af426 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -1,23 +1,21 @@ -import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import clsx from 'clsx'; import { compact, uniq } from 'es-toolkit/compat'; import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; -import React, { useMemo, useRef, useState } from 'react'; +import React, { useMemo, useRef } from 'react'; import { AdminConfigManager } from '~core/config/config.class'; import type { TimelineNodeBlockComponentState } from '~modules/content-page/types/content-block.types'; import { Color } from '~modules/content-page/types/content-block.types'; -import { IeObjectType } from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types'; import Html from '~shared/components/Html/Html'; import { Icon } from '~shared/components/Icon/Icon'; -import { SmartLink } from '~shared/components/SmartLink/SmartLink'; import { SanitizePreset } from '~shared/helpers/sanitize/presets'; import { tText } from '~shared/helpers/translation-functions'; import { HET_ARCHIEF } from '~shared/types'; import type { DefaultComponentProps } from '~shared/types/components'; -import type { TimelineIeObject } from './hooks/useGetTimelineIeObjects'; +import { BlockTimelineObject } from './BlockTimelineObject'; import { useGetTimelineIeObjects } from './hooks/useGetTimelineIeObjects'; import './BlockTimeline.scss'; +import { BlockTimelineObjectMeta } from '~content-blocks/BlockTimeline/BlockTimelineObjectMeta.tsx'; export interface BlockTimelineProps extends DefaultComponentProps { elements: TimelineNodeBlockComponentState[]; @@ -38,90 +36,6 @@ const TimelineCap: FunctionComponent<{ position: 'start' | 'end' }> = ({ positio ); -// Formats that the AudioOrVideoPlayer can play. Other formats (newspaper, image) fall back to the thumbnail. -const PLAYABLE_FORMATS: IeObjectType[] = [ - IeObjectType.VIDEO, - IeObjectType.VIDEO_FRAGMENT, - IeObjectType.AUDIO, - IeObjectType.AUDIO_FRAGMENT, - IeObjectType.FILM, -]; - -const BlockTimelineObject: FunctionComponent<{ - ieObject: TimelineIeObject; - fallbackTitle: string; -}> = ({ ieObject, fallbackTitle }) => { - const [isPaused, setIsPaused] = useState(true); - const AudioOrVideoPlayer = AdminConfigManager.getConfig().components?.audioOrVideoPlayer; - - // The player only needs the first representation that contains a file, same as the object detail page - const representation = (ieObject.pages || []) - .flatMap((page) => page?.representations || []) - .find((rep) => !!rep?.files?.length); - - const isPlayable = - !!AudioOrVideoPlayer && - !!representation && - !!ieObject.dctermsFormat && - PLAYABLE_FORMATS.includes(ieObject.dctermsFormat); - - return ( -
-
- {isPlayable && AudioOrVideoPlayer ? ( - setIsPaused(false)} - onPause={() => setIsPaused(true)} - onMediaReady={() => undefined} - /> - ) : ( - ieObject.thumbnailUrl && ( - {ieObject.name - ) - )} -
-
-

{ieObject.name || fallbackTitle}

-
- - {tText( - 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-volledig-fragment', - {}, - [HET_ARCHIEF] - )} - - {ieObject.maintainerLogo && ( - {ieObject.maintainerName - )} -
-
-
- ); -}; - export const BlockTimeline: FunctionComponent = ({ className, elements = [], @@ -246,6 +160,9 @@ export const BlockTimeline: FunctionComponent = ({ /> )}
+ {node.visualType === 'OBJECT' && ieObject && ( + + )}
); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx new file mode 100644 index 00000000..c7da2ffd --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx @@ -0,0 +1,65 @@ +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; +import { AdminConfigManager } from '~core/config/config.class'; +import { IeObjectType } from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types'; +import { BlockTimelineObjectMeta } from './BlockTimelineObjectMeta'; +import type { TimelineIeObject } from './hooks/useGetTimelineIeObjects'; + +// Formats that the AudioOrVideoPlayer can play. Other formats (newspaper, image) fall back to the thumbnail. +const PLAYABLE_FORMATS: IeObjectType[] = [ + IeObjectType.VIDEO, + IeObjectType.VIDEO_FRAGMENT, + IeObjectType.AUDIO, + IeObjectType.AUDIO_FRAGMENT, + IeObjectType.FILM, +]; + +export const BlockTimelineObject: FunctionComponent<{ + ieObject: TimelineIeObject; + fallbackTitle: string; +}> = ({ ieObject, fallbackTitle }) => { + const [isPaused, setIsPaused] = useState(true); + const AudioOrVideoPlayer = AdminConfigManager.getConfig().components?.audioOrVideoPlayer; + + // The player only needs the first representation that contains a file, same as the object detail page + const representation = (ieObject.pages || []) + .flatMap((page) => page?.representations || []) + .find((rep) => !!rep?.files?.length); + + const isPlayable = + !!AudioOrVideoPlayer && + !!representation && + !!ieObject.dctermsFormat && + PLAYABLE_FORMATS.includes(ieObject.dctermsFormat); + + return ( +
+
+ {isPlayable && AudioOrVideoPlayer ? ( + setIsPaused(false)} + onPause={() => setIsPaused(true)} + onMediaReady={() => undefined} + /> + ) : ( + ieObject.thumbnailUrl && ( + {ieObject.name + ) + )} +
+
+ ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx new file mode 100644 index 00000000..2cb44609 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx @@ -0,0 +1,44 @@ +import { Button } from '@meemoo/react-components'; +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { SmartLink } from '~shared/components/SmartLink/SmartLink'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; +import type { TimelineIeObject } from './hooks/useGetTimelineIeObjects'; + +export const BlockTimelineObjectMeta: FunctionComponent<{ + ieObject: TimelineIeObject; + fallbackTitle: string; +}> = ({ ieObject, fallbackTitle }) => ( +
+ + + +
+

{ieObject.name || fallbackTitle}

+

+ {ieObject.maintainerName || ''} +

+
+ {ieObject.maintainerLogo && ( + {ieObject.maintainerName + )} +
+); diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index bcf3e80f..030e8669 100644 --- a/ui/src/shared/helpers/admin-core-config.tsx +++ b/ui/src/shared/helpers/admin-core-config.tsx @@ -183,7 +183,17 @@ export function getAdminCoreConfigForLocalTestApp(navigateFunc: NavigateFunction defaultAudioStill: 'FAKE_DEFAULT_AUDIO_STILL', enableMultiLanguage: true, audioOrVideoPlayer: () => ( -
AudioOrVideoPlayer
+
+ AudioOrVideoPlayer +
), buttonTypes: () => [ // Het archief buttons From 51db6761ee469015c2d40e5c03818b21fadcb3de Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Thu, 13 Aug 2026 18:05:51 +0200 Subject: [PATCH 08/12] feat(ARC-3815): enhance timeline block responsive layout and object media styling Refines the responsive design of timeline nodes, particularly for items with images and objects. Addresses object title truncation issues on smaller screens and introduces distinct mobile spacing and CTA. Repositions object metadata within the content area to facilitate the new responsive layout. https://meemoo.atlassian.net/browse/ARC-3815 --- .../blocks/BlockTimeline/BlockTimeline.scss | 100 ++++++++++++++---- .../blocks/BlockTimeline/BlockTimeline.tsx | 14 +-- .../BlockTimeline/BlockTimelineObjectMeta.tsx | 19 +++- 3 files changed, 103 insertions(+), 30 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss index 8adc77c4..e5119b0d 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -15,7 +15,8 @@ $marker-rectangle-height: 0.3rem; // Aspect ratio for the node's image / video / audio media. $node-media-aspect-ratio: '1215 / 630'; // Vertical space between every item on the timeline (start cap, nodes, end cap). -$item-gap: 12rem; +$item-gap-desktop: 12rem; +$item-gap-mobile: 5rem; // Inner padding of a node that has a background colour. $node-background-padding: 4rem; // Gap between a node's media (image / object) and the start of its background colour band. @@ -46,7 +47,11 @@ $node-background-top-gap: 5rem; } > li:not(:first-child):not(:last-child) { - margin-bottom: $item-gap; + margin-bottom: $item-gap-desktop; + + @media (max-width: variables.$g-bp3) { + margin-bottom: $item-gap-mobile; + } } > li:first-child { @@ -83,7 +88,10 @@ $node-background-top-gap: 5rem; &__node { display: grid; - grid-template-columns: 1fr; + // minmax(0, ...) instead of a bare 1fr: 1fr means minmax(auto, 1fr), which lets the + // column grow to the min-content width of its item (e.g. the object meta bar's title) + // instead of clamping to the available space. That growth is what breaks truncation. + grid-template-columns: minmax(0, 1fr); grid-template-areas: 'year' 'date' @@ -136,6 +144,10 @@ $node-background-top-gap: 5rem; &__node-content { grid-area: content; + // Grid items default to min-width: auto, so wide content would push the column wider + // than the track. Together with the minmax(0, 1fr) tracks this keeps the column at the + // available width and lets the object title ellipse inside it. + min-width: 0; margin-top: 0.5rem; padding-left: $line-content-gap; @@ -160,8 +172,22 @@ $node-background-top-gap: 5rem; background-color: var(--c-block-timeline-node-bg); } - &.c-block-timeline__node-content--has-media::before { + &.c-block-timeline__node-content--has-image::before { + top: $node-background-top-gap; + bottom: 0; + + @media (max-width: variables.$g-bp3) { + bottom: 0; + } + } + + &.c-block-timeline__node-content--has-object::before { top: $node-background-top-gap; + bottom: 10rem; + + @media (max-width: variables.$g-bp3) { + bottom: 13rem; + } } > * { @@ -199,7 +225,6 @@ $node-background-top-gap: 5rem; &__node-image-wrapper { position: relative; z-index: 1; - width: 100%; } &__node-image { @@ -239,7 +264,6 @@ $node-background-top-gap: 5rem; &__node-object-media { position: relative; z-index: 1; - width: 100%; } &__node-object-image { @@ -264,17 +288,35 @@ $node-background-top-gap: 5rem; padding: 0.75rem 1rem; background-color: colors.$color-platinum; height: 6rem; - position: absolute; - bottom: -6rem; - left: 0; - max-width: 100%; + margin-left: -$line-content-gap; + margin-top: 4rem; + + @media (max-width: variables.$g-bp3) { + height: 9rem; + flex-direction: row-reverse; + } } - &__node-object-cta { + &__node-object-cta--desktop, + &__node-object-cta--mobile { flex: 0 0 auto; white-space: nowrap; } + &__node-object-cta--mobile { + display: none; + } + + @media (max-width: variables.$g-bp3) { + &__node-object-cta--desktop { + display: none; + } + + &__node-object-cta--mobile { + display: block; + } + } + &__node-object-text { flex: 1 1 auto; min-width: 0; @@ -285,7 +327,15 @@ $node-background-top-gap: 5rem; margin: 0; font-weight: 700; display: block; + } + + &__node-object-title { + flex-shrink: 1; @include typography.truncate; + + @media (max-width: variables.$g-bp3) { + @include typography.truncate-after-x-lines(2); + } } &__node-object-maintainer-name { @@ -297,6 +347,10 @@ $node-background-top-gap: 5rem; height: 2.4rem; width: auto; margin: 0 variables.$g-spacer-unit * 1.5 0 variables.$g-spacer-unit * 3; + + @media (max-width: variables.$g-bp3) { + display: none; + } } &__back-to-top { @@ -332,7 +386,7 @@ $node-background-top-gap: 5rem; } &__node { - grid-template-columns: $year-column-width-desktop 1fr; + grid-template-columns: $year-column-width-desktop minmax(0, 1fr); grid-template-areas: 'year date' '. content'; @@ -348,6 +402,11 @@ $node-background-top-gap: 5rem; &__node-object-media { margin-left: calc(-1 * (#{$year-column-width-desktop} + #{$line-content-gap})); width: calc(100% + #{$year-column-width-desktop} + #{$line-content-gap}); + + @media (max-width: variables.$g-bp3) { + margin-left: calc(-1 * (#{$year-column-width-mobile} + #{$line-content-gap})); + width: calc(100% + #{$year-column-width-mobile} + #{$line-content-gap}); + } } @media (max-width: variables.$g-bp3) { @@ -359,23 +418,20 @@ $node-background-top-gap: 5rem; margin-left: calc(#{$year-column-width-mobile} - #{$marker-size} / 2); } + // The year sits to the right of the line, stacked above the date. &__node { - grid-template-columns: $year-column-width-mobile 1fr; + grid-template-columns: $year-column-width-mobile minmax(0, 1fr); grid-template-areas: - 'year date' + '. year' + '. date' '. content'; } &__node-year { - padding-right: 1rem; - padding-left: 0; + padding-right: 0; + padding-left: $line-content-gap; font-size: 2.5rem; - } - - &__node-image-wrapper, - &__node-object-media { - margin-left: calc(-1 * (#{$year-column-width-mobile} + #{$line-content-gap})); - width: calc(100% + #{$year-column-width-mobile} + #{$line-content-gap}); + line-height: 3rem; } } } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index 347af426..5a511127 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -87,9 +87,8 @@ export const BlockTimeline: FunctionComponent = ({ node.visualType === 'OBJECT' && node.mediaItem?.value ? ieObjectsByPid?.[String(node.mediaItem.value)] : undefined; - const hasMedia = - (node.visualType === 'OBJECT' && !!node.mediaItem?.value) || - (node.visualType === 'IMAGE' && !!node.image); + const hasImage = node.visualType === 'IMAGE' && !!node.image; + const hasObject = node.visualType === 'OBJECT' && !!node.mediaItem?.value; return (
  • = ({
    = ({ className="c-block-timeline__node-description" /> )} + {node.visualType === 'OBJECT' && ieObject && ( + + )}
    - {node.visualType === 'OBJECT' && ieObject && ( - - )}
  • ); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx index 2cb44609..11b52120 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx @@ -1,4 +1,5 @@ import { Button } from '@meemoo/react-components'; +import { Icon, type IconName } from '@viaa/avo2-components'; import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import type { FunctionComponent } from 'react'; import React from 'react'; @@ -17,7 +18,7 @@ export const BlockTimelineObjectMeta: FunctionComponent<{ type: AvoCoreContentPickerType.INTERNAL_LINK, value: `/pid/${ieObject.schemaIdentifier}`, }} - className="c-block-timeline__node-object-cta" + className="c-block-timeline__node-object-cta--desktop" > + + +

    {ieObject.name || fallbackTitle}

    From 42a80e3dc55487ba96275bdcbebd026545a070db Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 17 Aug 2026 14:16:09 +0200 Subject: [PATCH 09/12] fix(ARC-3815): use reusable CopyrightAttribution component https://meemoo.atlassian.net/browse/ARC-3815 --- .../blocks/BlockAvoHero/BlockAvoHero.tsx | 7 ++- .../BlockObjectsGrid.types.ts | 4 +- .../BlockThemeReels/BlockThemeReelSection.tsx | 4 +- .../BlockTimeline.editorconfig.ts | 52 ++++++++----------- .../blocks/BlockTimeline/BlockTimeline.tsx | 24 +++------ .../content-page/types/content-block.types.ts | 6 +-- .../AudioOrVideoPlayer.types.ts | 14 ++--- .../modules/shared/helpers/icon.ts | 20 +++---- .../modules/shared/helpers/mapFormatToType.ts | 16 +++--- .../services/themes-service/themes.types.ts | 4 +- 10 files changed, 65 insertions(+), 86 deletions(-) diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx index 1d8273a3..5a3955bf 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockAvoHero/BlockAvoHero.tsx @@ -89,8 +89,11 @@ export const BlockAvoHero: FunctionComponent = ({ )} {!!textBelowButtons && ( - {isString(textBelowButtons) && } - {!isString(textBelowButtons) && textBelowButtons} + {isString(textBelowButtons) ? ( + + ) : ( + textBelowButtons + )} )}

    diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts index b5247297..51ec0ad5 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts @@ -1,7 +1,7 @@ import type { HeadingTypeOption } from '~modules/content-page/types/content-block.types'; import type { DefaultComponentProps } from '~modules/shared/types/components'; import type { PickerItem } from '~modules/shared/types/content-picker'; -import type { ObjectType } from '~shared/helpers/mapFormatToType.ts'; +import type { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; export type OrderedTile = { item: ObjectsGridItem; isFixed: boolean }; @@ -21,7 +21,7 @@ export interface ObjectsGridItem { // Provider / maintainer name, shown in the title bar. maintainerName?: string; // Object type, drives the type-icon. Falls back to a generic icon when unknown. - type?: ObjectType; + type?: IeObjectType; // Thumbnail (video / newspaper / image). Absent for audio → waveform fallback is shown. thumbnailUrl?: string; } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx index 7f09f0d8..1f2c8e12 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockThemeReels/BlockThemeReelSection.tsx @@ -17,7 +17,7 @@ import { isMobileWidth } from '~shared/helpers/media-query.ts'; import { tText } from '~shared/helpers/translation-functions.ts'; import 'swiper/css'; import './BlockThemeReelsSection.scss'; -import type { ObjectType } from '~shared/helpers/mapFormatToType.ts'; +import type { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; export interface BlockThemeReelSectionProps extends DefaultComponentProps { themeId: string; @@ -69,7 +69,7 @@ export const BlockThemeReelSection: FunctionComponent { return ( <> diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts index aa15e0a7..e87ad8f1 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts @@ -12,7 +12,13 @@ import type { } from '../../../types/content-block.types'; import { Color, ContentBlockEditor, ContentBlockType } from '../../../types/content-block.types'; -import { BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, FILE_FIELD, TEXT_FIELD } from '../defaults'; +import { + BLOCK_FIELD_DEFAULTS, + BLOCK_STATE_DEFAULTS, + COPYRIGHT_FIELDS, + FILE_FIELD, + TEXT_FIELD, +} from '../defaults'; const GET_TIMELINE_NODE_VISUAL_TYPE_OPTIONS = (): SelectOption[] => [ { @@ -199,34 +205,22 @@ export const TIMELINE_BLOCK_CONFIG = (position = 0): ContentBlockConfig => ({ validator: undefined, isVisible: visualTypeIsImage, }), - imageCaptionCopyright: TEXT_FIELD({ - label: tText( - 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-copyright', - {}, - [HET_ARCHIEF] - ), - validator: undefined, - isVisible: visualTypeIsImage, - }), - imageCaptionCopyrightIconVisible: { - editorType: ContentBlockEditor.Checkbox, - editorProps: { - label: tText( - 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___copyright-icoon-toevoegen', - {}, - [HET_ARCHIEF] - ), - } as CheckboxProps, - isVisible: visualTypeIsImage, - }, - imageCaptionDescription: TEXT_FIELD({ - label: tText( - 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-beschrijving', - {}, - [HET_ARCHIEF] - ), - validator: undefined, - isVisible: visualTypeIsImage, + ...COPYRIGHT_FIELDS({ + title: { + overrides: { + isVisible: visualTypeIsImage, + }, + }, + showIcon: { + overrides: { + isVisible: visualTypeIsImage, + }, + }, + text: { + overrides: { + isVisible: visualTypeIsImage, + }, + }, }), backgroundColor: { label: tText( diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index 5a511127..39b9abbc 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -16,6 +16,7 @@ import { useGetTimelineIeObjects } from './hooks/useGetTimelineIeObjects'; import './BlockTimeline.scss'; import { BlockTimelineObjectMeta } from '~content-blocks/BlockTimeline/BlockTimelineObjectMeta.tsx'; +import { CopyrightAttribution } from '~shared/components/CopyrightAttribution'; export interface BlockTimelineProps extends DefaultComponentProps { elements: TimelineNodeBlockComponentState[]; @@ -133,23 +134,12 @@ export const BlockTimeline: FunctionComponent = ({
    )}
    - {(node.imageCaptionCopyright || node.imageCaptionDescription) && ( -
    - {node.imageCaptionCopyright && ( -

    - {node.imageCaptionCopyrightIconVisible !== false && ( - - )} - {node.imageCaptionCopyright} -

    - )} - {node.imageCaptionDescription && ( -

    - {node.imageCaptionDescription} -

    - )} -
    - )} +

    {node.title}

    {node.text && ( void; onPause: () => void; + onEnded: () => void; onMediaReady: (isAvailable: boolean, playableFile: IeObjectFile | null) => void; onMediaDurationLoaded?: (duration: number) => void; dctermsFormat: IeObjectType | null; diff --git a/ui/src/react-admin/modules/shared/helpers/icon.ts b/ui/src/react-admin/modules/shared/helpers/icon.ts index e663f145..4a239d37 100644 --- a/ui/src/react-admin/modules/shared/helpers/icon.ts +++ b/ui/src/react-admin/modules/shared/helpers/icon.ts @@ -1,22 +1,22 @@ import type { IconName } from '@viaa/avo2-components'; -import { ObjectType } from '~shared/helpers/mapFormatToType.ts'; +import { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; -export function getIconFromObjectType(format: ObjectType | undefined): IconName { +export function getIconFromObjectType(format: IeObjectType | undefined): IconName { switch (format) { - case ObjectType.film: - case ObjectType.video: - case ObjectType.videofragment: + case IeObjectType.film: + case IeObjectType.video: + case IeObjectType.videofragment: return 'no-video--light' as IconName; - case ObjectType.audio: - case ObjectType.audiofragment: + case IeObjectType.audio: + case IeObjectType.audiofragment: return 'no-audio--light' as IconName; - case ObjectType.newspaper: - case ObjectType.newspaperpage: + case IeObjectType.newspaper: + case IeObjectType.newspaperpage: return 'no-newspaper--light' as IconName; - case ObjectType.image: + case IeObjectType.image: return 'no-image--light' as IconName; default: diff --git a/ui/src/react-admin/modules/shared/helpers/mapFormatToType.ts b/ui/src/react-admin/modules/shared/helpers/mapFormatToType.ts index bdb1d2e4..c677b08c 100644 --- a/ui/src/react-admin/modules/shared/helpers/mapFormatToType.ts +++ b/ui/src/react-admin/modules/shared/helpers/mapFormatToType.ts @@ -1,27 +1,27 @@ -export enum ObjectType { +export enum IeObjectType { audio = 'audio', audiofragment = 'audiofragment', film = 'film', - image = 'image', + image = 'image', // Should never be used, but does seem to pop up some times newspaper = 'newspaper', - newspaperpage = 'newspaperpage', + newspaperpage = 'newspaperpage', // Should never be used, but does seem to pop up some times video = 'video', videofragment = 'videofragment', } -export function mapFormatToType(format?: string): ObjectType | undefined { +export function mapFormatToType(format?: string): IeObjectType | undefined { switch ((format || '').toLowerCase()) { case 'video': case 'film': - return ObjectType.video; + return IeObjectType.video; case 'audio': - return ObjectType.audio; + return IeObjectType.audio; case 'newspaper': case 'krant': - return ObjectType.newspaper; + return IeObjectType.newspaper; case 'image': case 'photo': - return ObjectType.image; + return IeObjectType.image; default: return undefined; } 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 index 992fc3f6..60f80298 100644 --- 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 @@ -1,4 +1,4 @@ -import type { ObjectType } from '~shared/helpers/mapFormatToType.ts'; +import type { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; export interface Theme { id: string; @@ -17,7 +17,7 @@ export interface ThemeWithObjects extends Theme { id: string; schemaIdentifier: string; name: string; - format: ObjectType; + format: IeObjectType; thumbnailUrl: string; maintainerId: string; maintainerName: string; From 72e0de81549743e942e80a16b201fe84f9983cc1 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 17 Aug 2026 15:41:32 +0200 Subject: [PATCH 10/12] 6.0.18 https://meemoo.atlassian.net/browse/ --- api/package-lock.json | 4 ++-- api/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- ui/package-lock.json | 4 ++-- ui/package.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index d026b438..923573d8 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meemoo/admin-core-api", - "version": "6.0.17", + "version": "6.0.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meemoo/admin-core-api", - "version": "6.0.17", + "version": "6.0.18", "license": "UNLICENSED", "dependencies": { "@aws-sdk/client-s3": "3.717.0", diff --git a/api/package.json b/api/package.json index b3082120..3787c39e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@meemoo/admin-core-api", - "version": "6.0.17", + "version": "6.0.18", "description": "The backend routes to support the screens in the Admin Core dashboard", "author": "Bert Verhelst ", "main": "./dist/src/index.js", diff --git a/package-lock.json b/package-lock.json index c9ca49d5..92beb91c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meemoo/react-admin", - "version": "6.0.17", + "version": "6.0.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meemoo/react-admin", - "version": "6.0.17", + "version": "6.0.18", "license": "ISC", "devDependencies": { "@babel/preset-env": "7.18.2", diff --git a/package.json b/package.json index 004b6101..9416771f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@meemoo/react-admin", - "version": "6.0.17", + "version": "6.0.18", "description": "React admin app for meemoo projects", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/ui/package-lock.json b/ui/package-lock.json index fbeb095c..b1237a27 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meemoo/admin-core-ui", - "version": "6.0.17", + "version": "6.0.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meemoo/admin-core-ui", - "version": "6.0.17", + "version": "6.0.18", "dependencies": { "jsdom": "27.4.0", "memoizee": "0.4.17", diff --git a/ui/package.json b/ui/package.json index 37ecb49a..0f24fa79 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "@meemoo/admin-core-ui", - "version": "6.0.17", + "version": "6.0.18", "private": false, "type": "module", "files": [ From 23c05b7cc7170ca46216a07af9507437a50cb1a6 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 17 Aug 2026 16:11:05 +0200 Subject: [PATCH 11/12] feat(ARC-3815): introduce shared IE object media component and refactor timeline block https://meemoo.atlassian.net/browse/ARC-3815 Extracts IE object media rendering into a reusable `IeObjectMedia` component, replacing the dedicated `BlockTimelineObject`. This improves modularity and consistency across content blocks. Also includes refactored styling for enhanced responsive layouts, dynamic CTA labels, and standardized date formatting and copyright attribution. --- .../BlockTimeline.editorconfig.ts | 7 +- .../blocks/BlockTimeline/BlockTimeline.scss | 234 +++++++----------- .../blocks/BlockTimeline/BlockTimeline.tsx | 35 +-- .../BlockTimeline/BlockTimelineObject.tsx | 65 ----- .../BlockTimeline/BlockTimelineObjectMeta.tsx | 131 ++++++---- .../hooks/useGetTimelineIeObjects.ts | 27 +- .../IeObjectMedia/IeObjectMedia.scss | 20 ++ .../IeObjectMedia/IeObjectMedia.tsx | 82 ++++++ .../IeObjectMedia/IeObjectMedia.types.ts | 18 ++ .../shared/components/IeObjectMedia/index.ts | 2 + .../modules/shared/helpers/formatters/date.ts | 40 ++- ui/src/shared/translations/hetArchief/nl.json | 9 +- 12 files changed, 351 insertions(+), 319 deletions(-) delete mode 100644 ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx create mode 100644 ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.scss create mode 100644 ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx create mode 100644 ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.types.ts create mode 100644 ui/src/react-admin/modules/shared/components/IeObjectMedia/index.ts diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts index e87ad8f1..281a2d84 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts @@ -1,4 +1,4 @@ -import type { CheckboxProps, SelectOption } from '@viaa/avo2-components'; +import type { SelectOption } from '@viaa/avo2-components'; import { GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF } from '~modules/content-page/const/get-color-options'; import type { FileUploadProps } from '~shared/components/FileUpload/FileUpload'; import { tText } from '~shared/helpers/translation-functions'; @@ -16,6 +16,7 @@ import { BLOCK_FIELD_DEFAULTS, BLOCK_STATE_DEFAULTS, COPYRIGHT_FIELDS, + COPYRIGHT_STATE, FILE_FIELD, TEXT_FIELD, } from '../defaults'; @@ -62,9 +63,7 @@ export const INITIAL_TIMELINE_COMPONENTS_STATE = (): TimelineNodeBlockComponentS mediaItem: undefined, image: undefined, imageAlt: '', - imageCaptionCopyright: '', - imageCaptionCopyrightIconVisible: true, - imageCaptionDescription: '', + ...COPYRIGHT_STATE(), backgroundColor: Color.Transparent, }, ]; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss index e5119b0d..255df3c5 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -6,22 +6,35 @@ // line from $g-bp3 on. Below that breakpoint the years sit above the date, right of the line. $year-column-width-desktop: 17rem; $year-column-width-mobile: 4rem; + // Distance between the timeline line and the node date / content. $line-content-gap: 3rem; + // The circle / rectangle markers: one per node (alternating), plus a fixed circle-rectangle-circle // cluster at the very start and end of the timeline. $marker-size: 0.4375rem; $marker-rectangle-height: 0.3rem; + // Aspect ratio for the node's image / video / audio media. $node-media-aspect-ratio: '1215 / 630'; + // Vertical space between every item on the timeline (start cap, nodes, end cap). $item-gap-desktop: 12rem; $item-gap-mobile: 5rem; + // Inner padding of a node that has a background colour. $node-background-padding: 4rem; + // Gap between a node's media (image / object) and the start of its background colour band. $node-background-top-gap: 5rem; +// The meta bar under an object node, and the gap between it and the node's text. It is taller on +// mobile because the title wraps over two lines there. The background colour band of an object node +// stops right above this bar, so its bottom offset is derived from these two values. +$object-meta-height-desktop: 6rem; +$object-meta-height-mobile: 9rem; +$object-meta-gap: 4rem; + .c-block-timeline { position: relative; @@ -38,15 +51,19 @@ $node-background-top-gap: 5rem; position: absolute; top: 0; bottom: 0; - left: 0; + left: $year-column-width-desktop; transform: translateX(-50%); width: 1px; background-color: currentcolor; opacity: 0.3; z-index: 1; + + @media (max-width: variables.$g-bp3) { + left: $year-column-width-mobile; + } } - > li:not(:first-child):not(:last-child) { + > li:not(:first-child, :last-child) { margin-bottom: $item-gap-desktop; @media (max-width: variables.$g-bp3) { @@ -67,7 +84,11 @@ $node-background-top-gap: 5rem; flex-direction: column; align-items: stretch; width: $marker-size; - margin-left: calc(#{$marker-size} / -2); + margin-left: calc(#{$year-column-width-desktop} - #{$marker-size} / 2); + + @media (max-width: variables.$g-bp3) { + margin-left: calc(#{$year-column-width-mobile} - #{$marker-size} / 2); + } } &__cap-shape { @@ -88,23 +109,40 @@ $node-background-top-gap: 5rem; &__node { display: grid; + // minmax(0, ...) instead of a bare 1fr: 1fr means minmax(auto, 1fr), which lets the // column grow to the min-content width of its item (e.g. the object meta bar's title) // instead of clamping to the available space. That growth is what breaks truncation. - grid-template-columns: minmax(0, 1fr); + grid-template-columns: $year-column-width-desktop minmax(0, 1fr); grid-template-areas: - 'year' - 'date' - 'content'; + 'year date' + '. content'; + + // The year sits to the right of the line, stacked above the date. + @media (max-width: variables.$g-bp3) { + grid-template-columns: $year-column-width-mobile minmax(0, 1fr); + grid-template-areas: + '. year' + '. date' + '. content'; + } } &__node-year { grid-area: year; align-self: baseline; - padding-left: $line-content-gap; - font-size: 4.8rem; - font-weight: 800; + padding-right: 1rem; + + @include typography.sofia-pro-heading-lg; + + // Taller than the font itself, so the year lines up with the top of the node's content line-height: 5.6rem; + + @media (max-width: variables.$g-bp3) { + padding-right: 0; + padding-left: $line-content-gap; + line-height: 3rem; + } } &__node-date { @@ -113,12 +151,9 @@ $node-background-top-gap: 5rem; position: relative; display: block; padding-left: $line-content-gap; - font-family: "Sofia Pro", sans-serif; - font-size: 1.5rem; - font-weight: 800; - font-style: normal; - line-height: 26px; - letter-spacing: 0; + + @include typography.sofia-pro-heading; + color: colors.$color-gray-300; } @@ -144,6 +179,7 @@ $node-background-top-gap: 5rem; &__node-content { grid-area: content; + // Grid items default to min-width: auto, so wide content would push the column wider // than the track. Together with the minmax(0, 1fr) tracks this keeps the column at the // available width and lets the object title ellipse inside it. @@ -174,19 +210,15 @@ $node-background-top-gap: 5rem; &.c-block-timeline__node-content--has-image::before { top: $node-background-top-gap; - bottom: 0; - - @media (max-width: variables.$g-bp3) { - bottom: 0; - } } + // Stops right above the object's meta bar, which sits outside the coloured band. &.c-block-timeline__node-content--has-object::before { top: $node-background-top-gap; - bottom: 10rem; + bottom: $object-meta-gap + $object-meta-height-desktop; @media (max-width: variables.$g-bp3) { - bottom: 13rem; + bottom: $object-meta-gap + $object-meta-height-mobile; } } @@ -203,28 +235,31 @@ $node-background-top-gap: 5rem; &__node-title { margin: variables.$g-spacer-unit * 4 0 0; - font-family: "Sofia Pro", sans-serif; - font-size: 1.5rem; - font-weight: 800; - font-style: normal; - line-height: 26px; - letter-spacing: 0; + + @include typography.sofia-pro-heading; } &__node-description { margin-top: 0.25rem; - font-family: "Sofia Pro", sans-serif; - font-weight: 500; - font-style: normal; - line-height: 28px; - letter-spacing: 0; - font-size: 18px; + @include typography.sofia-pro-body; } - &__node-image-wrapper { + // A node's media (image or object) breaks out of the content column and starts at the very + // left of the block, so it also covers the year column and the gap next to the timeline line. + // The media itself is styled by BlockTimeline's img rule / the shared IeObjectMedia component, + // only its placement inside the node is timeline specific. + &__node-image-wrapper, + &__node-object-media { position: relative; z-index: 1; + margin-left: calc(-1 * (#{$year-column-width-desktop} + #{$line-content-gap})); + width: calc(100% + #{$year-column-width-desktop} + #{$line-content-gap}); + + @media (max-width: variables.$g-bp3) { + margin-left: calc(-1 * (#{$year-column-width-mobile} + #{$line-content-gap})); + width: calc(100% + #{$year-column-width-mobile} + #{$line-content-gap}); + } } &__node-image { @@ -239,47 +274,6 @@ $node-background-top-gap: 5rem; padding-top: 0.5rem; } - &__node-image-copyright { - margin: 0; - font-family: "Sofia Pro", sans-serif; - font-size: 10px; - font-weight: 500; - font-style: normal; - line-height: 15px; - letter-spacing: 0; - color: var(--c-block-timeline-copyright-color, colors.$color-gray-300); - } - - &__node-image-description { - margin: 0.25rem 0 variables.$g-spacer-unit * 4; - font-family: "Sofia Pro", sans-serif; - font-size: 10px; - font-weight: 500; - font-style: normal; - line-height: 15px; - letter-spacing: 0; - color: colors.$color-gray-1000; - } - - &__node-object-media { - position: relative; - z-index: 1; - } - - &__node-object-image { - display: block; - width: 100%; - aspect-ratio: #{$node-media-aspect-ratio}; - object-fit: cover; - object-position: top; - } - - // The player renders its own aspect ratio (video) or fixed height (audio), it only needs the full width - &__node-object-player { - display: block; - width: 100%; - } - &__node-object-meta { display: flex; align-items: center; @@ -287,32 +281,32 @@ $node-background-top-gap: 5rem; gap: 1rem; padding: 0.75rem 1rem; background-color: colors.$color-platinum; - height: 6rem; + height: $object-meta-height-desktop; margin-left: -$line-content-gap; - margin-top: 4rem; + margin-top: $object-meta-gap; @media (max-width: variables.$g-bp3) { - height: 9rem; + height: $object-meta-height-mobile; flex-direction: row-reverse; } } - &__node-object-cta--desktop, - &__node-object-cta--mobile { + &__node-object-cta { flex: 0 0 auto; white-space: nowrap; } - &__node-object-cta--mobile { + // Below $g-bp3 the meta bar has no room for the label, so the call to action shrinks to its icon. + &__node-object-cta-icon { display: none; } @media (max-width: variables.$g-bp3) { - &__node-object-cta--desktop { + &__node-object-cta-label { display: none; } - &__node-object-cta--mobile { + &__node-object-cta-icon { display: block; } } @@ -325,12 +319,13 @@ $node-background-top-gap: 5rem; &__node-object-title, &__node-object-maintainer-name { margin: 0; - font-weight: 700; display: block; } &__node-object-title { flex-shrink: 1; + + @include typography.sofia-pro-heading-sm; @include typography.truncate; @media (max-width: variables.$g-bp3) { @@ -339,7 +334,7 @@ $node-background-top-gap: 5rem; } &__node-object-maintainer-name { - font-weight: 500; + @include typography.sofia-pro-body-xs; } &__node-object-maintainer-logo { @@ -364,12 +359,8 @@ $node-background-top-gap: 5rem; padding: 0; cursor: pointer; color: colors.$color-gray-300; - font-family: "Sofia Pro", sans-serif; - font-size: 1rem; - font-weight: 500; - font-style: normal; - line-height: 20px; - letter-spacing: 0; + + @include typography.sofia-pro-body-xs; &-icon { width: 1rem; @@ -377,61 +368,4 @@ $node-background-top-gap: 5rem; } } - &__list::before { - left: $year-column-width-desktop; - } - - &__cap { - margin-left: calc(#{$year-column-width-desktop} - #{$marker-size} / 2); - } - - &__node { - grid-template-columns: $year-column-width-desktop minmax(0, 1fr); - grid-template-areas: - 'year date' - '. content'; - } - - &__node-year { - padding-right: 1rem; - padding-left: 0; - font-size: 2.5rem; - } - - &__node-image-wrapper, - &__node-object-media { - margin-left: calc(-1 * (#{$year-column-width-desktop} + #{$line-content-gap})); - width: calc(100% + #{$year-column-width-desktop} + #{$line-content-gap}); - - @media (max-width: variables.$g-bp3) { - margin-left: calc(-1 * (#{$year-column-width-mobile} + #{$line-content-gap})); - width: calc(100% + #{$year-column-width-mobile} + #{$line-content-gap}); - } - } - - @media (max-width: variables.$g-bp3) { - &__list::before { - left: $year-column-width-mobile; - } - - &__cap { - margin-left: calc(#{$year-column-width-mobile} - #{$marker-size} / 2); - } - - // The year sits to the right of the line, stacked above the date. - &__node { - grid-template-columns: $year-column-width-mobile minmax(0, 1fr); - grid-template-areas: - '. year' - '. date' - '. content'; - } - - &__node-year { - padding-right: 0; - padding-left: $line-content-gap; - font-size: 2.5rem; - line-height: 3rem; - } - } } diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx index 39b9abbc..e1ec0a8f 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -7,26 +7,22 @@ import type { TimelineNodeBlockComponentState } from '~modules/content-page/type import { Color } from '~modules/content-page/types/content-block.types'; import Html from '~shared/components/Html/Html'; import { Icon } from '~shared/components/Icon/Icon'; +import { formatDateToDayMonthNameYear, getYear } from '~shared/helpers/formatters/date'; import { SanitizePreset } from '~shared/helpers/sanitize/presets'; import { tText } from '~shared/helpers/translation-functions'; import { HET_ARCHIEF } from '~shared/types'; import type { DefaultComponentProps } from '~shared/types/components'; -import { BlockTimelineObject } from './BlockTimelineObject'; import { useGetTimelineIeObjects } from './hooks/useGetTimelineIeObjects'; import './BlockTimeline.scss'; import { BlockTimelineObjectMeta } from '~content-blocks/BlockTimeline/BlockTimelineObjectMeta.tsx'; import { CopyrightAttribution } from '~shared/components/CopyrightAttribution'; +import { IeObjectMedia } from '~shared/components/IeObjectMedia'; export interface BlockTimelineProps extends DefaultComponentProps { elements: TimelineNodeBlockComponentState[]; } -const isValidDate = (date: string): boolean => !Number.isNaN(new Date(date).getTime()); - -const getYear = (date: string): number | null => - isValidDate(date) ? new Date(date).getFullYear() : null; - // The timeline starts and ends with the same fixed circle/rectangle/circle cluster of markers. // Every node in between alternates circle, rectangle, circle, rectangle, ... based on its index. const TimelineCap: FunctionComponent<{ position: 'start' | 'end' }> = ({ position }) => ( @@ -60,15 +56,6 @@ export const BlockTimeline: FunctionComponent = ({ ); const { data: ieObjectsByPid } = useGetTimelineIeObjects(pids); - const formatDate = (date: string): string => - isValidDate(date) - ? new Intl.DateTimeFormat(locale, { - day: 'numeric', - month: 'long', - year: 'numeric', - }).format(new Date(date)) - : ''; - const scrollToTop = () => { containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; @@ -84,12 +71,9 @@ export const BlockTimeline: FunctionComponent = ({ ? node.backgroundColor : undefined; const markerShape = index % 2 === 0 ? 'circle' : 'rectangle'; - const ieObject = - node.visualType === 'OBJECT' && node.mediaItem?.value - ? ieObjectsByPid?.[String(node.mediaItem.value)] - : undefined; const hasImage = node.visualType === 'IMAGE' && !!node.image; const hasObject = node.visualType === 'OBJECT' && !!node.mediaItem?.value; + const ieObject = hasObject ? ieObjectsByPid?.[String(node.mediaItem?.value)] : undefined; return (
  • = ({ )} aria-hidden="true" /> - {formatDate(node.date)} + {formatDateToDayMonthNameYear(node.date, locale)}
    = ({ : undefined } > - {node.visualType === 'OBJECT' && ieObject && ( - + {ieObject && ( + )} {node.visualType === 'IMAGE' && node.image && (
    @@ -149,7 +138,7 @@ export const BlockTimeline: FunctionComponent = ({ className="c-block-timeline__node-description" /> )} - {node.visualType === 'OBJECT' && ieObject && ( + {ieObject && ( )}
    diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx deleted file mode 100644 index c7da2ffd..00000000 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObject.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { FunctionComponent } from 'react'; -import React, { useState } from 'react'; -import { AdminConfigManager } from '~core/config/config.class'; -import { IeObjectType } from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types'; -import { BlockTimelineObjectMeta } from './BlockTimelineObjectMeta'; -import type { TimelineIeObject } from './hooks/useGetTimelineIeObjects'; - -// Formats that the AudioOrVideoPlayer can play. Other formats (newspaper, image) fall back to the thumbnail. -const PLAYABLE_FORMATS: IeObjectType[] = [ - IeObjectType.VIDEO, - IeObjectType.VIDEO_FRAGMENT, - IeObjectType.AUDIO, - IeObjectType.AUDIO_FRAGMENT, - IeObjectType.FILM, -]; - -export const BlockTimelineObject: FunctionComponent<{ - ieObject: TimelineIeObject; - fallbackTitle: string; -}> = ({ ieObject, fallbackTitle }) => { - const [isPaused, setIsPaused] = useState(true); - const AudioOrVideoPlayer = AdminConfigManager.getConfig().components?.audioOrVideoPlayer; - - // The player only needs the first representation that contains a file, same as the object detail page - const representation = (ieObject.pages || []) - .flatMap((page) => page?.representations || []) - .find((rep) => !!rep?.files?.length); - - const isPlayable = - !!AudioOrVideoPlayer && - !!representation && - !!ieObject.dctermsFormat && - PLAYABLE_FORMATS.includes(ieObject.dctermsFormat); - - return ( -
    -
    - {isPlayable && AudioOrVideoPlayer ? ( - setIsPaused(false)} - onPause={() => setIsPaused(true)} - onMediaReady={() => undefined} - /> - ) : ( - ieObject.thumbnailUrl && ( - {ieObject.name - ) - )} -
    -
    - ); -}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx index 11b52120..eae1942c 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx @@ -3,59 +3,94 @@ import { Icon, type IconName } from '@viaa/avo2-components'; import { AvoCoreContentPickerType } from '@viaa/avo2-types'; import type { FunctionComponent } from 'react'; import React from 'react'; +import type { IeObjectMediaInfo } from '~shared/components/IeObjectMedia'; import { SmartLink } from '~shared/components/SmartLink/SmartLink'; +import { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; import { tText } from '~shared/helpers/translation-functions'; import { HET_ARCHIEF } from '~shared/types'; -import type { TimelineIeObject } from './hooks/useGetTimelineIeObjects'; -export const BlockTimelineObjectMeta: FunctionComponent<{ - ieObject: TimelineIeObject; - fallbackTitle: string; -}> = ({ ieObject, fallbackTitle }) => ( -
    - - - - { + switch (format) { + case IeObjectType.video: + case IeObjectType.videofragment: + case IeObjectType.film: + return tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-de-volledige-video', + {}, + [HET_ARCHIEF] + ); + + case IeObjectType.audio: + case IeObjectType.audiofragment: + return tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___beluister-de-volledige-audio', + {}, + [HET_ARCHIEF] + ); + + case IeObjectType.newspaper: + case IeObjectType.newspaperpage: + return tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-de-volledige-krant', + {}, + [HET_ARCHIEF] + ); + + case IeObjectType.image: + return tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-de-volledige-afbeelding', + {}, + [HET_ARCHIEF] + ); + + default: + return tText( + 'react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-het-volledige-object', {}, [HET_ARCHIEF] + ); + } +}; + +export const BlockTimelineObjectMeta: FunctionComponent<{ + ieObject: IeObjectMediaInfo; + fallbackTitle: string; +}> = ({ ieObject, fallbackTitle }) => { + const callToActionLabel = getCallToActionLabel(ieObject.dctermsFormat); + + return ( +
    + {/* The label is swapped for an icon below $g-bp3, where the meta bar has no room for it */} + + + +
    +

    {ieObject.name || fallbackTitle}

    +

    + {ieObject.maintainerName || ''} +

    +
    + {ieObject.maintainerLogo && ( + {ieObject.maintainerName )} - className="c-block-timeline__node-object-cta--mobile" - > - - -
    -

    {ieObject.name || fallbackTitle}

    -

    - {ieObject.maintainerName || ''} -

    - {ieObject.maintainerLogo && ( - {ieObject.maintainerName - )} -
    -); + ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts index 9adc7c0e..25ae44fa 100644 --- a/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts @@ -1,31 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { compact, keyBy } from 'es-toolkit/compat'; import { stringifyUrl } from 'query-string'; -import type { - IeObjectRepresentation, - IeObjectType, -} from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types'; +import type { IeObjectMediaInfo } from '~shared/components/IeObjectMedia'; 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 { QUERY_KEYS } from '~shared/types'; -/** - * The subset of the ie-object we need to render a timeline node. - * The proxy returns camel case properties for this endpoint. - */ -export interface TimelineIeObject { - schemaIdentifier: string; - name: string; - thumbnailUrl?: string; - dctermsFormat: IeObjectType | null; - duration?: string; - maintainerName?: string; - maintainerLogo?: string | null; - maintainerOverlay?: boolean | null; - pages?: { representations: IeObjectRepresentation[] }[]; -} - /** * Resolves the ie-objects for all timeline nodes with visualType OBJECT in a single request. * Same endpoint the hetarchief client uses: IeObjectsService.getBySchemaIdentifiers() @@ -33,9 +14,9 @@ export interface TimelineIeObject { */ const fetchTimelineIeObjects = async ( pids: string[] -): Promise> => { +): Promise> => { try { - const ieObjects = await fetchWithLogoutJson<(TimelineIeObject | null)[]>( + const ieObjects = await fetchWithLogoutJson<(IeObjectMediaInfo | null)[]>( stringifyUrl({ url: `${getProxyUrl()}/ie-objects`, query: { @@ -51,7 +32,7 @@ const fetchTimelineIeObjects = async ( }; export const useGetTimelineIeObjects = (pids: string[]) => { - return useQuery>({ + return useQuery>({ queryKey: [QUERY_KEYS.GET_IE_OBJECT, ...pids], queryFn: () => fetchTimelineIeObjects(pids), enabled: pids.length > 0, diff --git a/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.scss b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.scss new file mode 100644 index 00000000..4406b888 --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.scss @@ -0,0 +1,20 @@ +// Aspect ratio for the thumbnail fallback, matching the one the player uses for video. +$ie-object-media-aspect-ratio: '1215 / 630'; + +.c-ie-object-media { + position: relative; + + &__image { + display: block; + width: 100%; + aspect-ratio: #{$ie-object-media-aspect-ratio}; + object-fit: cover; + object-position: top; + } + + // The player renders its own aspect ratio (video) or fixed height (audio), it only needs the full width + &__player { + display: block; + width: 100%; + } +} diff --git a/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx new file mode 100644 index 00000000..f255266a --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx @@ -0,0 +1,82 @@ +import clsx from 'clsx'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; +import { AdminConfigManager } from '~core/config/config.class'; +import type { IeObjectMediaInfo } from '~shared/components/IeObjectMedia/IeObjectMedia.types'; +import { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; +import type { DefaultComponentProps } from '~shared/types/components'; + +import './IeObjectMedia.scss'; + +// Formats that the AudioOrVideoPlayer can play. Other formats (newspaper, image) fall back to the thumbnail. +const PLAYABLE_FORMATS: IeObjectType[] = [ + IeObjectType.video, + IeObjectType.videofragment, + IeObjectType.audio, + IeObjectType.audiofragment, + IeObjectType.film, +]; + +export interface IeObjectMediaProps extends DefaultComponentProps { + ieObject: IeObjectMediaInfo; + // Shown as the image alt text when the object itself has no name + fallbackTitle: string; + // Identifies the player instance for the client that renders it (eg: analytics) + locationId: string; +} + +/** + * Renders the media of an ie-object: the audio/video player for playable formats, the thumbnail + * for everything else. Shared between the content blocks that show a single ie-object + * (eg: the timeline block and the hero carousel). + */ +export const IeObjectMedia: FunctionComponent = ({ + className, + ieObject, + fallbackTitle, + locationId, +}) => { + const [isPaused, setIsPaused] = useState(true); + const AudioOrVideoPlayer = AdminConfigManager.getConfig().components?.audioOrVideoPlayer; + + // The player only needs the first representation that contains a file, same as the object detail page + const representation = (ieObject.pages || []) + .flatMap((page) => page?.representations || []) + .find((rep) => !!rep?.files?.length); + + const isPlayable = + !!AudioOrVideoPlayer && + !!representation && + !!ieObject.dctermsFormat && + PLAYABLE_FORMATS.includes(ieObject.dctermsFormat); + + return ( +
    + {isPlayable && AudioOrVideoPlayer ? ( + setIsPaused(false)} + onPause={() => setIsPaused(true)} + onEnded={() => setIsPaused(true)} + onMediaReady={() => undefined} + /> + ) : ( + ieObject.thumbnailUrl && ( + {ieObject.name + ) + )} +
    + ); +}; diff --git a/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.types.ts b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.types.ts new file mode 100644 index 00000000..dae43881 --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.types.ts @@ -0,0 +1,18 @@ +import type { IeObjectRepresentation } from '~shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types'; +import type { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; + +/** + * The subset of an ie-object that is needed to render its media (player or thumbnail). + * The proxy returns camel case properties for this endpoint. + */ +export interface IeObjectMediaInfo { + schemaIdentifier: string; + name: string; + thumbnailUrl?: string; + dctermsFormat: IeObjectType | null; + duration?: string; + maintainerName?: string; + maintainerLogo?: string | null; + maintainerOverlay?: boolean | null; + pages?: { representations: IeObjectRepresentation[] }[]; +} diff --git a/ui/src/react-admin/modules/shared/components/IeObjectMedia/index.ts b/ui/src/react-admin/modules/shared/components/IeObjectMedia/index.ts new file mode 100644 index 00000000..9257b755 --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/IeObjectMedia/index.ts @@ -0,0 +1,2 @@ +export * from './IeObjectMedia.tsx'; +export * from './IeObjectMedia.types.ts'; diff --git a/ui/src/react-admin/modules/shared/helpers/formatters/date.ts b/ui/src/react-admin/modules/shared/helpers/formatters/date.ts index 66120708..6c074ad4 100644 --- a/ui/src/react-admin/modules/shared/helpers/formatters/date.ts +++ b/ui/src/react-admin/modules/shared/helpers/formatters/date.ts @@ -1,9 +1,12 @@ -import { format, formatDistance, parseISO } from 'date-fns'; -import { nlBE } from 'date-fns/locale'; +import { format, formatDistance, isValid, parseISO } from 'date-fns'; +import { enGB, nlBE } from 'date-fns/locale'; +import { Locale } from '~modules/translations/translations.core.types'; type DateLike = string | Date | number; type DateLikeNullable = DateLike | undefined | null; +const DATE_FNS_LOCALES = { [Locale.Nl]: nlBE, [Locale.En]: enGB }; + /** * Converts a date from format 2000-12-31 to 31/12/2000 */ @@ -94,3 +97,36 @@ export function formatDateToDayMonthYear(dateString: string | undefined | null): } return format(parseISO(dateString), 'd MMM. yyyy', { locale: nlBE }); } + +/** + * Formats a date with its month written out in full, in the given locale. + * For example, '2020-09-15' becomes '15 september 2020' (nl) or '15 September 2020' (en). + * Returns an empty string for missing or unparsable dates. + * + * @param timestamp The date to format. + * @param locale The locale to write the month name in. Defaults to Dutch. + */ +export function formatDateToDayMonthNameYear( + timestamp: DateLikeNullable, + locale: Locale = Locale.Nl +): string { + if (!timestamp) { + return ''; + } + const date = normalizeTimestamp(timestamp); + if (!isValid(date)) { + return ''; + } + return format(date, 'd MMMM yyyy', { locale: DATE_FNS_LOCALES[locale] }); +} + +/** + * Returns the year of a date, or null when the date is missing or unparsable. + */ +export function getYear(timestamp: DateLikeNullable): number | null { + if (!timestamp) { + return null; + } + const date = normalizeTimestamp(timestamp); + return isValid(date) ? date.getFullYear() : null; +} diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index 6bb0df74..b94cd3af 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -1042,10 +1042,11 @@ "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___achtergrondkleur-van-de-node": "Achtergrondkleur van de node", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___afbeelding": "Afbeelding", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___alternatieve-tekst-afbeelding": "Alternatieve tekst afbeelding", - "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-volledig-fragment": "Bekijk volledig fragment", - "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-beschrijving": "Bijschrift: beschrijving", - "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bijschrift-copyright": "Bijschrift: copyright", - "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___copyright-icoon-toevoegen": "Copyright icoon toevoegen", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-de-volledige-afbeelding": "Bekijk de volledige afbeelding", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-de-volledige-krant": "Bekijk de volledige krant", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-de-volledige-video": "Bekijk de volledige video", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___bekijk-het-volledige-object": "Bekijk het volledige object", + "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___beluister-de-volledige-audio": "Beluister de volledige audio", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___datum": "Datum", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___datum-is-verplicht": "Datum is verplicht", "react-admin/modules/content-page/components/blocks/block-timeline/block-timeline___een-afbeelding-is-verplicht": "Een afbeelding is verplicht", From 75f3536f5c8eb1720d1be97722b780e568d63f72 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Mon, 17 Aug 2026 16:15:14 +0200 Subject: [PATCH 12/12] fix(ARC-3815): replace default audio object thumbnails with custom still https://meemoo.atlassian.net/browse/ARC-3815 The proxy-generated thumbnail for audio objects is an unappealing speaker icon. This update replaces it with a client-defined default audio still (e.g., a waveform image), enhancing visual consistency for audio media. --- .../components/IeObjectMedia/IeObjectMedia.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx index f255266a..928af41b 100644 --- a/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx +++ b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx @@ -17,6 +17,8 @@ const PLAYABLE_FORMATS: IeObjectType[] = [ IeObjectType.film, ]; +const AUDIO_FORMATS: IeObjectType[] = [IeObjectType.audio, IeObjectType.audiofragment]; + export interface IeObjectMediaProps extends DefaultComponentProps { ieObject: IeObjectMediaInfo; // Shown as the image alt text when the object itself has no name @@ -37,7 +39,8 @@ export const IeObjectMedia: FunctionComponent = ({ locationId, }) => { const [isPaused, setIsPaused] = useState(true); - const AudioOrVideoPlayer = AdminConfigManager.getConfig().components?.audioOrVideoPlayer; + const { audioOrVideoPlayer: AudioOrVideoPlayer, defaultAudioStill } = + AdminConfigManager.getConfig().components; // The player only needs the first representation that contains a file, same as the object detail page const representation = (ieObject.pages || []) @@ -50,6 +53,13 @@ export const IeObjectMedia: FunctionComponent = ({ !!ieObject.dctermsFormat && PLAYABLE_FORMATS.includes(ieObject.dctermsFormat); + // The thumbnail the proxy returns for audio is a signed link to an ugly speaker icon, so we + // show the client's own waveform image instead. Same as the client does for its media cards. + const thumbnailUrl = + ieObject.dctermsFormat && AUDIO_FORMATS.includes(ieObject.dctermsFormat) + ? defaultAudioStill + : ieObject.thumbnailUrl; + return (
    {isPlayable && AudioOrVideoPlayer ? ( @@ -61,7 +71,7 @@ export const IeObjectMedia: FunctionComponent = ({ schemaIdentifier={ieObject.schemaIdentifier} maintainerLogo={ieObject.maintainerOverlay ? ieObject.maintainerLogo : undefined} cuePoints={undefined} - poster={ieObject.thumbnailUrl} + poster={thumbnailUrl} paused={isPaused} onPlay={() => setIsPaused(false)} onPause={() => setIsPaused(true)} @@ -69,9 +79,9 @@ export const IeObjectMedia: FunctionComponent = ({ onMediaReady={() => undefined} /> ) : ( - ieObject.thumbnailUrl && ( + thumbnailUrl && ( {ieObject.name