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": [ diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index f625fa01..97629f09 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -13,6 +13,7 @@ import type { } from '~modules/content-page/types/content-pages.types'; 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 { @@ -116,7 +117,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/ContentBlockRenderer/ContentBlockRenderer.const.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx index 58286ba1..13899fc8 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 @@ -36,6 +36,7 @@ import { BlockProjectSpotlightWrapper, BlockSpotlight } from '~content-blocks/Bl import { BlockTagsWithLink } from '~content-blocks/BlockTagsWithLink'; import { BlockThemeReels } from '~content-blocks/BlockThemeReels'; 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'; @@ -99,6 +100,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.HighlightText]: BlockHighlightText, [ContentBlockType.ThemeReels]: BlockThemeReels, [ContentBlockType.OverviewThemes]: BlockOverviewThemes, + [ContentBlockType.Timeline]: BlockTimeline, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), @@ -130,6 +132,7 @@ export const REPEATABLE_CONTENT_BLOCKS = [ ContentBlockType.TagsWithLink, ContentBlockType.CardsWithoutDescription, ContentBlockType.OverviewThemes, + ContentBlockType.Timeline, // ContentBlockType.ContentEncloseGrid, ]; 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..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 new file mode 100644 index 00000000..281a2d84 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.editorconfig.ts @@ -0,0 +1,244 @@ +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'; +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, + COPYRIGHT_FIELDS, + COPYRIGHT_STATE, + 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: '', + ...COPYRIGHT_STATE(), + backgroundColor: Color.Transparent, + }, +]; + +export const INITIAL_TIMELINE_BLOCK_STATE = (): TimelineBlockState => ({ + ...BLOCK_STATE_DEFAULTS(), +}); + +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, + // Only one type is allowed, so the type dropdown would only ever have a single option + hideTypeDropdown: 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, + }), + ...COPYRIGHT_FIELDS({ + title: { + overrides: { + isVisible: visualTypeIsImage, + }, + }, + showIcon: { + overrides: { + isVisible: visualTypeIsImage, + }, + }, + text: { + overrides: { + 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(), + }, + }, +}); 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..255df3c5 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.scss @@ -0,0 +1,371 @@ +@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-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; + + &__list { + position: relative; + margin: 0; + 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: $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, :last-child) { + margin-bottom: $item-gap-desktop; + + @media (max-width: variables.$g-bp3) { + margin-bottom: $item-gap-mobile; + } + } + + > li:first-child { + margin-bottom: 6rem; + } + } + + // 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; + display: flex; + flex-direction: column; + align-items: stretch; + width: $marker-size; + 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 { + flex-shrink: 0; + width: 100%; + height: $marker-size; + background-color: currentcolor; + + &--circle { + border-radius: 50%; + } + + &--rectangle { + height: $marker-rectangle-height; + border-radius: 0; + } + } + + &__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: $year-column-width-desktop minmax(0, 1fr); + grid-template-areas: + '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-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 { + grid-area: date; + align-self: baseline; + position: relative; + display: block; + padding-left: $line-content-gap; + + @include typography.sofia-pro-heading; + + color: colors.$color-gray-300; + } + + // 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 { + 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; + + &--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-image::before { + top: $node-background-top-gap; + } + + // 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: $object-meta-gap + $object-meta-height-desktop; + + @media (max-width: variables.$g-bp3) { + bottom: $object-meta-gap + $object-meta-height-mobile; + } + } + + > * { + position: relative; + z-index: 1; + } + } + } + + &__node-text { + padding-top: 1rem; + } + + &__node-title { + margin: variables.$g-spacer-unit * 4 0 0; + + @include typography.sofia-pro-heading; + } + + &__node-description { + margin-top: 0.25rem; + + @include typography.sofia-pro-body; + } + + // 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 { + display: block; + width: 100%; + aspect-ratio: #{$node-media-aspect-ratio}; + object-fit: cover; + object-position: center; + } + + &__node-image-caption { + padding-top: 0.5rem; + } + + &__node-object-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background-color: colors.$color-platinum; + height: $object-meta-height-desktop; + margin-left: -$line-content-gap; + margin-top: $object-meta-gap; + + @media (max-width: variables.$g-bp3) { + height: $object-meta-height-mobile; + flex-direction: row-reverse; + } + } + + &__node-object-cta { + flex: 0 0 auto; + white-space: nowrap; + } + + // 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-label { + display: none; + } + + &__node-object-cta-icon { + display: block; + } + } + + &__node-object-text { + flex: 1 1 auto; + min-width: 0; + } + + &__node-object-title, + &__node-object-maintainer-name { + margin: 0; + display: block; + } + + &__node-object-title { + flex-shrink: 1; + + @include typography.sofia-pro-heading-sm; + @include typography.truncate; + + @media (max-width: variables.$g-bp3) { + @include typography.truncate-after-x-lines(2); + } + } + + &__node-object-maintainer-name { + @include typography.sofia-pro-body-xs; + } + + &__node-object-maintainer-logo { + flex: 0 0 auto; + 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 { + display: flex; + align-items: center; + gap: 0.375rem; + width: fit-content; + margin: 1.5rem 0 0 auto; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: colors.$color-gray-300; + + @include typography.sofia-pro-body-xs; + + &-icon { + width: 1rem; + height: 1rem; + } + } + +} 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..e1ec0a8f --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimeline.tsx @@ -0,0 +1,163 @@ +import clsx from 'clsx'; +import { compact, uniq } from 'es-toolkit/compat'; +import type { CSSProperties, FunctionComponent, ReactElement } 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 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 { 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[]; +} + +// 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 }) => ( + +); + +export const BlockTimeline: FunctionComponent = ({ + className, + elements = [], +}): ReactElement => { + 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 scrollToTop = () => { + containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( +
+
    + + {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 hasImage = node.visualType === 'IMAGE' && !!node.image; + const hasObject = node.visualType === 'OBJECT' && !!node.mediaItem?.value; + const ieObject = hasObject ? ieObjectsByPid?.[String(node.mediaItem?.value)] : undefined; + + return ( +
  1. + {showYear && ( + {getYear(node.date)} + )} + +
    + {ieObject && ( + + )} + {node.visualType === 'IMAGE' && node.image && ( +
    + {node.imageAlt +
    + )} +
    + +

    {node.title}

    + {node.text && ( + + )} + {ieObject && ( + + )} +
    +
    +
  2. + ); + })} + +
+ {elements.length > 0 && ( + + )} +
+ ); +}; 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..eae1942c --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/BlockTimelineObjectMeta.tsx @@ -0,0 +1,96 @@ +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'; +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'; + +// The call to action names the kind of object it links to, so a newspaper isn't announced as a fragment. +const getCallToActionLabel = (format: IeObjectType | null): string => { + 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 + )} +
+ ); +}; 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..25ae44fa --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockTimeline/hooks/useGetTimelineIeObjects.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query'; +import { compact, keyBy } from 'es-toolkit/compat'; +import { stringifyUrl } from 'query-string'; +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'; + +/** + * 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<(IeObjectMediaInfo | 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 + }); +}; 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 6265d370..616abfc0 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 @@ -43,6 +43,7 @@ import { import { TAGS_WITH_LINK_BLOCK_CONFIG } from '~content-blocks/BlockTagsWithLink'; import { THEME_REELS_BLOCK_CONFIG } from '~content-blocks/BlockThemeReels'; 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'; @@ -99,4 +100,5 @@ export const CONTENT_BLOCK_CONFIG_MAP: Record< [ContentBlockType.ThemeReels]: THEME_REELS_BLOCK_CONFIG, [ContentBlockType.ObjectsGrid]: OBJECTS_GRID_BLOCK_CONFIG, [ContentBlockType.OverviewThemes]: OVERVIEW_THEMES_BLOCK_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 05ecb74f..5f1a8214 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 @@ -44,6 +44,7 @@ import { import { INITIAL_TAGS_WITH_LINK_COMPONENTS_STATE } from '~content-blocks/BlockTagsWithLink'; import { INITIAL_THEME_REELS_COMPONENTS_STATE } from '~content-blocks/BlockThemeReels'; 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'; @@ -102,4 +103,5 @@ export const CONTENT_BLOCK_INITIAL_STATE_MAP: { [ContentBlockType.ThemeReels]: INITIAL_THEME_REELS_COMPONENTS_STATE, [ContentBlockType.ObjectsGrid]: INITIAL_OBJECTS_GRID_BLOCK_STATE, [ContentBlockType.OverviewThemes]: INITIAL_OVERVIEW_THEMES_COMPONENTS_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 66da92a9..27e777a6 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 @@ -228,6 +228,14 @@ export const GET_CONTENT_BLOCK_TYPE_OPTIONS: () => SelectOption[] = () = ), value: ContentBlockType.OverviewThemes, }, + { + 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/types/content-block.types.ts b/ui/src/react-admin/modules/content-page/types/content-block.types.ts index 2967497e..2a3ee7c0 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 @@ -194,6 +194,7 @@ export enum ContentBlockType { ThemeReels = 'THEME_REELS', ObjectsGrid = 'OBJECTS_GRID', OverviewThemes = 'OVERVIEW_THEMES', + Timeline = 'TIMELINE', } export enum ContentBlockEditor { @@ -458,7 +459,8 @@ export type RepeatedContentBlockComponentState = | MediaGridBlockComponentState | ImageInfo // project spotlight & spotlight | RichTextBlockComponentState - | ThreeClickableTilesBlockComponentState; + | ThreeClickableTilesBlockComponentState + | TimelineNodeBlockComponentState; export type SingleContentBlockComponentState = | HeadingBlockComponentState @@ -618,3 +620,21 @@ export interface HetArchiefIeObject { name: string; schemaIdentifier: string; } + +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; + copyrightTitle?: string; + copyrightIconVisible?: boolean; + copyrightText?: string; + backgroundColor?: Color; +} + +export type TimelineBlockState = DefaultContentBlockState; diff --git a/ui/src/react-admin/modules/shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types.ts b/ui/src/react-admin/modules/shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types.ts new file mode 100644 index 00000000..d1c6350f --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/AudioOrVideoPlayer/AudioOrVideoPlayer.types.ts @@ -0,0 +1,53 @@ +import type { IeObjectType } from '~shared/helpers/mapFormatToType.ts'; + +export interface IeObjectFile { + id: string; + name: string; + mimeType: string; + storedAt: string; + thumbnailUrl: string; + duration: string; + edmIsNextInSequence: string; + createdAt: string; + mediaFragment: { + startTime: number; + endTime: number; + } | null; +} + +export interface IeObjectRepresentation { + id: string; + schemaName: string; + schemaInLanguage: string; + schemaStartTime: string; + schemaEndTime: string; + schemaTranscript: string; + schemaTranscriptUrl: string | null; + edmIsNextInSequence: string; + updatedAt: string; + isMediaFragmentOf: string; + files: IeObjectFile[]; +} + +export interface CuePoints { + end: number | null; + start: number | null; +} + +export type AudioOrVideoPlayerProps = { + className?: string; + allowFullScreen?: boolean; + paused: boolean; + onPlay: () => void; + onPause: () => void; + onEnded: () => 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/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..928af41b --- /dev/null +++ b/ui/src/react-admin/modules/shared/components/IeObjectMedia/IeObjectMedia.tsx @@ -0,0 +1,92 @@ +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, +]; + +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 + 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: 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 || []) + .flatMap((page) => page?.representations || []) + .find((rep) => !!rep?.files?.length); + + const isPlayable = + !!AudioOrVideoPlayer && + !!representation && + !!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 ? ( + setIsPaused(false)} + onPause={() => setIsPaused(true)} + onEnded={() => setIsPaused(true)} + onMediaReady={() => undefined} + /> + ) : ( + 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/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; diff --git a/ui/src/shared/helpers/admin-core-config.tsx b/ui/src/shared/helpers/admin-core-config.tsx index 0d8d0bed..64fe78fe 100644 --- a/ui/src/shared/helpers/admin-core-config.tsx +++ b/ui/src/shared/helpers/admin-core-config.tsx @@ -100,6 +100,7 @@ export function getAdminCoreConfigForLocalTestApp(navigateFunc: NavigateFunction ContentBlockType.HetArchiefQuote, ContentBlockType.ObjectsGrid, ContentBlockType.OverviewThemes, + ContentBlockType.Timeline, ], defaultPageWidth: ContentPageWidth.LARGE, onSaveContentPage: async (contentPageInfo: ContentPageInfo) => { @@ -184,96 +185,107 @@ 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: { diff --git a/ui/src/shared/translations/hetArchief/nl.json b/ui/src/shared/translations/hetArchief/nl.json index 2ff13efe..bbc58265 100644 --- a/ui/src/shared/translations/hetArchief/nl.json +++ b/ui/src/shared/translations/hetArchief/nl.json @@ -1039,6 +1039,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-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", + "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___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 >", @@ -1054,6 +1077,7 @@ "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/views/content-page-edit___bezig": "Bezig...",