diff --git a/.prettierrc.json b/.prettierrc.json index f7a558b..ca4dd7d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -12,7 +12,7 @@ "vueIndentScriptAndStyle": false, "proseWrap": "preserve", "insertPragma": false, - "printWidth": 100, + "printWidth": 140, "requirePragma": false, "tabWidth": 4, "useTabs": false, diff --git a/README.md b/README.md index 549deb1..9b6de76 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,17 @@ update any relevant styles or configurations as needed. After adding a new component, you will likely need to fix the code style to adhere to our formatting standards. Additionally, ensure the cn import is correctly handled, as it may need to be adjusted. + +# Live Release + +This project uses **Docker Hub Automated Builds** for continuous deployment: + +1. **Code Push**: When code is pushed to the GitHub repository, Docker Hub automatically detects the changes +2. **Automatic Build**: Docker Hub executes the `hooks/build` script to build the Docker image +3. **Environment Selection**: The build script automatically selects production or development environment variables based on the git branch: + - `main` branch → Production environment variables (`latest` tag) + - `dev` branch → Development environment variables (`latest-test` tag) +4. **Image Tagging**: The built image is tagged with the git commit hash and branch-specific tags: + - `main` → `seebruecke/bside-website:a1b2c3d` + `seebruecke/bside-website:latest` + - `dev` → `seebruecke/bside-website:b2c3d4e` + `seebruecke/bside-website:latest-test` +5. **Registry Push**: The image is automatically pushed to Docker Hub diff --git a/components/blocks/buttonBlock/Button.tsx b/components/blocks/buttonBlock/Button.tsx index 4793195..bdc8a4d 100644 --- a/components/blocks/buttonBlock/Button.tsx +++ b/components/blocks/buttonBlock/Button.tsx @@ -5,7 +5,7 @@ import isEmptyString from '@/lib/common/helper/isEmptyString'; import { toKebabCase } from '@/lib/common/toKebabCase'; interface Props { - title: string | undefined; + title?: string | null; text: string; href: string; target?: '_blank' | '_self'; diff --git a/components/blocks/callToActionBlock/CallToAction.tsx b/components/blocks/callToActionBlock/CallToAction.tsx index 9720260..9217434 100644 --- a/components/blocks/callToActionBlock/CallToAction.tsx +++ b/components/blocks/callToActionBlock/CallToAction.tsx @@ -5,7 +5,7 @@ import isEmptyString from '@/lib/common/helper/isEmptyString'; import { toKebabCase } from '@/lib/common/toKebabCase'; interface Props { - title: string | undefined; + title?: string | null; text: string; href: string; withArrows?: boolean; diff --git a/components/blocks/mediaBlock/MediaBlock.tsx b/components/blocks/mediaBlock/MediaBlock.tsx index 2cc84b5..a821beb 100644 --- a/components/blocks/mediaBlock/MediaBlock.tsx +++ b/components/blocks/mediaBlock/MediaBlock.tsx @@ -1,8 +1,10 @@ import clsx from 'clsx'; import Image from 'next/image'; import type { ReactElement } from 'react'; +import { isNil } from 'lodash-es'; import ContentWrapper from '@/components/layout/ContentWrapper'; import isEmptyString from '@/lib/common/helper/isEmptyString'; +import isEmptyNumber from '@/lib/common/helper/isEmptyNumber'; import type { MediaBlockProps } from '@/types/payload/Blocks'; const WideMediaBlock = ( @@ -28,20 +30,34 @@ const WideMediaBlock = ( const MediaBlock = ({ media, - caption = '', + caption, size, effects, }: MediaBlockProps): ReactElement | null => { if (typeof media === 'string') { + console.warn('Unexpectedly media is just a string, this is not supported.'); return null; } - if (media.url === undefined || media.url === null || isEmptyString(media.url)) { + if (isEmptyString(media.url)) { + console.warn('Unexpectedly media URL is not set, something went wrong here.'); return null; } - if (size === 'wide' && media.sizes?.wide?.url !== undefined) { - return WideMediaBlock(media.sizes.wide.url!, effects ?? []); + if (size === 'wide') { + if (isNil(media.sizes?.wide?.url)) { + console.warn('Unexpectedly wide version of image is not available.'); + return null; + } + + return WideMediaBlock(media.sizes.wide.url, effects ?? []); + } + + let [width, height] = [media.width, media.height]; + + if (isEmptyNumber(width) || isEmptyNumber(height)) { + console.warn('Unexpectedly size of image is not set, using somewhat arbitrary fallback.'); + [width, height] = [100, 100]; } // ToDo: Implement the square version of the image. @@ -52,8 +68,8 @@ const MediaBlock = ({ {media.alt} ); diff --git a/components/blocks/reusableLayout/ReusableBlocks.tsx b/components/blocks/reusableLayout/ReusableBlocks.tsx index 9d58a3e..4db33f2 100644 --- a/components/blocks/reusableLayout/ReusableBlocks.tsx +++ b/components/blocks/reusableLayout/ReusableBlocks.tsx @@ -31,7 +31,7 @@ const ReusableBlocks = ({ case 'callToAction': return ( @@ -40,7 +40,7 @@ const ReusableBlocks = ({ case 'content': return ( @@ -50,9 +50,9 @@ const ReusableBlocks = ({ return ( ); @@ -62,9 +62,9 @@ const ReusableBlocks = ({ media={layoutElement.media} richText={layoutElement.richText} alignment={layoutElement.alignment} - headline={layoutElement.headline!} + headline={layoutElement.headline} backgroundColor={layoutElement.backgroundColor} - effects={layoutElement.effects!} + effects={layoutElement.effects} /> ); @@ -119,6 +119,9 @@ const ReusableBlocks = ({ return ; default: + // @ts-expect-error | This should not happen, just precaution. + console.warn(`Received unexpected block type ${layoutElement.blockType}`); + return null; } }; diff --git a/components/blocks/richTextBlock/serializeRichTextToHtml.tsx b/components/blocks/richTextBlock/serializeRichTextToHtml.tsx index 521d9be..456232b 100644 --- a/components/blocks/richTextBlock/serializeRichTextToHtml.tsx +++ b/components/blocks/richTextBlock/serializeRichTextToHtml.tsx @@ -9,6 +9,17 @@ import type { SlateChildren } from '@/types/payload/Blocks'; import type { Media as MediaType } from '@/types/payload/payload-types'; import InlineButton from '@blocks/buttonBlock/InlineButton'; import HeadlineTag from 'components/blocks/headlineBlock/HeadlineTag'; +import isNotEmptyString from '@/lib/common/helper/isNotEmptyString'; +import isEmptyString from '@/lib/common/helper/isEmptyString'; + +interface LinkSlateChild { + type: 'link'; + linkType?: 'custom' | 'internal'; + url: string | null; + newTab: boolean; + fields?: { appearance: 'link' | 'button' }; + children: Array<{ text: string }>; +} export interface RichTextUploadNodeType { value?: MediaType; @@ -95,48 +106,52 @@ const serializeMedia = (node: Record): ReactElement | null => { ); }; -const serializeLink = ( - nodeChildren: SlateChildren, - node: Record, - index: number, -): ReactElement => { - // @ts-expect-error Needs to be typed. +const serializeLink = (node: LinkSlateChild, index: number): ReactElement => { + if (node.linkType === 'internal') { + // TODO: Add support for internal links - or prevent option in Payload. Also it seems to cause errors, when there's + // an internal link to a doc that's not public, e.g. a user. + console.warn('Link type "internal" is currently not supported!'); + return <> + } + + const linkText = node.children[0]?.text; + + if (isEmptyString(node.url) || isEmptyString(linkText)) { + // Might accidentally happen while pasting copied text into the rich text editor, or by some other mistake. + console.warn('Unexpectedly link URL or text is empty'); + return <>; + } + if (node.fields?.appearance === 'button') { return ( ); } - if ((node.url as string).startsWith('mailto:')) { - let mail = (node.url as string).substring(7); + if (isNotEmptyString(node.url) && node.url.startsWith('mailto:')) { + let mail = node.url.substring(7); mail = mail.startsWith('//') ? mail.substring(2) : mail; mail = mail.replace('.spam', '.ms'); return ( - + ); } + return ( - {} - {serializeRichTextToHtml(nodeChildren)} + {linkText} ); }; @@ -164,7 +179,7 @@ const serializeRichTextToHtml = (children: SlateChildren): Array; -}): ReactElement | null => { +} + +const ColumnsContent = ({ columns }: Props): ReactElement | null => { const [firstColumn, secondColumn, thirdColumn] = columns; if (firstColumn === undefined) { @@ -26,21 +26,15 @@ const ColumnsContent = ({ return ; case 'twoThirds': - return ( - - ); + return ; case 'oneThird': - return ( - - ); - } + return ; - return
; + default: + console.error(`Received unexpected width of first column: ${firstColumn.width}`) + return null; + } }; export default ColumnsContent; diff --git a/components/blocks/textBlock/ContentBlock.tsx b/components/blocks/textBlock/ContentBlock.tsx index c96b1e2..e369056 100644 --- a/components/blocks/textBlock/ContentBlock.tsx +++ b/components/blocks/textBlock/ContentBlock.tsx @@ -8,12 +8,6 @@ const ContentBlock = ({ backgroundWidth, columns, }: ContentProps): ReactElement | null => { - if (columns === undefined || columns.length === 0) { - console.warn('Columns must be set'); - - return null; - } - if (backgroundWidth === 'full' && backgroundColor === 'black') { return (
diff --git a/components/blocks/textBlock/OneThirdColumnsContent.tsx b/components/blocks/textBlock/OneThirdColumnsContent.tsx index 2096688..31ddbfa 100644 --- a/components/blocks/textBlock/OneThirdColumnsContent.tsx +++ b/components/blocks/textBlock/OneThirdColumnsContent.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; import type { ContentColumnProps } from '@/types/payload/Blocks'; import FullColumnContent from '@blocks/textBlock/FullColumnContent'; -import TwoThirdsColumnsContent from '@blocks/textBlock/TwoThirdsColumnsContent'; import RichText from 'components/blocks/richTextBlock/RichText'; interface Props { @@ -10,48 +9,26 @@ interface Props { thirdColumn?: ContentColumnProps; } -const OneThirdColumnsContent = ({ - firstColumn, - secondColumn, - thirdColumn, -}: Props): ReactElement | null => { - if (secondColumn === undefined) { - console.warn( - 'Falling back to full column instead of oneThird since second column is missing', - ); +const OneThirdColumnsContent = ({ firstColumn, secondColumn, thirdColumn }: Props): ReactElement => { + if (secondColumn === undefined || thirdColumn === undefined) { + console.warn('Falling back to full since second or third column is missing'); return ; } - // Three Columns - if (secondColumn.width === 'oneThird') { - if (thirdColumn === undefined) { - console.warn( - 'Falling back to two columns instead of oneThird since third column is missing', - ); - - return ( - - ); - } - - return ( -
-
- -
-
- -
-
- -
+ return ( +
+
+
- ); - } - - // Two Columns. - return ; +
+ +
+
+ +
+
+ ); }; export default OneThirdColumnsContent; diff --git a/components/blocks/textBlock/TwoThirdsColumnsContent.tsx b/components/blocks/textBlock/TwoThirdsColumnsContent.tsx index 8c99ae8..68066f9 100644 --- a/components/blocks/textBlock/TwoThirdsColumnsContent.tsx +++ b/components/blocks/textBlock/TwoThirdsColumnsContent.tsx @@ -8,11 +8,9 @@ interface Props { secondColumn?: ContentColumnProps; } -const TwoThirdsColumnsContent = ({ firstColumn, secondColumn }: Props): ReactElement | null => { +const TwoThirdsColumnsContent = ({ firstColumn, secondColumn, }: Props): ReactElement => { if (secondColumn === undefined) { - console.warn( - 'Falling back to full column instead of twoThirds since second column is missing', - ); + console.warn('Falling back to full column instead of twoThirds since second column is missing'); return ; } diff --git a/eslint.config.mjs b/eslint.config.mjs index 9e42655..e8b8d31 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,6 +40,7 @@ export default [ 'tailwindcss/no-custom-classname': 'error', + '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-non-null-assertion': 'warn', diff --git a/hooks/build b/hooks/build old mode 100644 new mode 100755 index b2dc312..dd0b508 --- a/hooks/build +++ b/hooks/build @@ -1,5 +1,20 @@ #!/bin/bash +# Docker Hub Automated Build Hook +# +# This script is automatically executed by Docker Hub's Automated Build system +# when code is pushed to the connected GitHub repository. Docker Hub provides +# the following environment variables: +# - SOURCE_BRANCH: The git branch being built (e.g., "main", "develop") +# - SOURCE_COMMIT: The git commit hash (short format) +# - IMAGE_NAME: The name of the Docker image being built +# - DOCKER_REPO: The Docker Hub repository path (e.g., "seebruecke/bside-website") +# +# Additional environment variables (PAYLOAD_URL_*, FRONTEND_URL_*, etc.) are +# configured in the Docker Hub repository settings and injected during build. +# +# Docker Hub is configured to only trigger builds for 'main' and 'dev' branches. + if [ $SOURCE_BRANCH = "main" ] then PAYLOAD_URL=$PAYLOAD_URL_PROD diff --git a/lib/payload/getPayloadResponse.ts b/lib/payload/getPayloadResponse.ts index b6b567a..5584077 100644 --- a/lib/payload/getPayloadResponse.ts +++ b/lib/payload/getPayloadResponse.ts @@ -13,7 +13,7 @@ const getPayloadResponse = async (path: string, preview: boolean = false): Pr ); if (!fetchResponse.ok) { - throw new Error(); + throw new Error(`Error code ${fetchResponse.status} while fetching ${path}: ${fetchResponse.statusText}`); } return fetchResponse.json() as T; diff --git a/next.config.js b/next.config.js index a55eca8..9eff1e8 100644 --- a/next.config.js +++ b/next.config.js @@ -12,6 +12,11 @@ const nextConfig = { hostname: 'cms.b-side.ms', pathname: '**', }, + { + protocol: 'http', + hostname: 'localhost', + pathname: '**', + }, ], minimumCacheTTL: 60 * 60 * 24, formats: ['image/webp', 'image/avif'], diff --git a/types/payload/Blocks.ts b/types/payload/Blocks.ts index 923c3bb..6b13d0d 100644 --- a/types/payload/Blocks.ts +++ b/types/payload/Blocks.ts @@ -18,9 +18,9 @@ export interface ContentColumnProps { } export interface ContentProps { - columns?: Array; - backgroundColor?: string; - backgroundWidth?: string; + columns: Array; + backgroundColor?: 'white' | 'black'; + backgroundWidth?: 'full' | 'block'; } export interface MediaContentBlockProps { @@ -28,20 +28,20 @@ export interface MediaContentBlockProps { richText: SlateChildren; alignment: MediaContentAlignment; backgroundColor?: MediaContentBackgroundColor; - headline?: string; + headline?: string | null; previousBlock?: string; - effects?: Array<'blur' | 'grayscale' | 'desaturated' | 'darker'>; + effects?: Array<'blur' | 'grayscale' | 'desaturated' | 'darker'> | null; } export interface MediaBlockProps { media: Media | string; size?: 'normal' | 'wide' | 'event'; - caption?: string; - effects?: Array<'blur' | 'grayscale' | 'desaturated' | 'darker'>; + caption?: string | null; + effects?: Array<'blur' | 'grayscale' | 'desaturated' | 'darker'> | null; } export interface CallToActionBlockProps { - title: string | undefined; + title?: string | null; text: string; href: string; } diff --git a/types/payload/payload-types.ts b/types/payload/payload-types.ts index acea000..3006da2 100644 --- a/types/payload/payload-types.ts +++ b/types/payload/payload-types.ts @@ -1,5 +1,5 @@ /* tslint:disable */ -/* eslint-disable */ + /** * This file was automatically generated by Payload. * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, @@ -60,18 +60,7 @@ export interface Event { eventOrganizer?: string | null; eventExtra?: string | null; category?: - | ( - | 'concert' - | 'movie' - | 'theater' - | 'plenum' - | 'workshop' - | 'party' - | 'exhibition' - | 'reading' - | 'lecture' - | 'other' - )[] + | ('concert' | 'movie' | 'theater' | 'plenum' | 'workshop' | 'party' | 'exhibition' | 'reading' | 'lecture' | 'other')[] | null; displayOnHome?: boolean | null; displayOnOverview?: boolean | null; @@ -187,22 +176,20 @@ export interface Circle { | { backgroundColor: 'white' | 'black'; backgroundWidth: 'full' | 'block'; - columns?: - | { - width: 'full' | 'half' | 'oneThird' | 'twoThirds'; - richText: { - [k: string]: unknown; - }[]; - id?: string | null; - }[] - | null; + columns: { + width: 'full' | 'half' | 'oneThird' | 'twoThirds'; + richText: { + [k: string]: unknown; + }[]; + id?: string | null; + }[]; id?: string | null; blockName?: string | null; blockType: 'content'; } | { media: string | Media; - size?: ('normal' | 'wide' | 'event') | null; + size: 'normal' | 'wide' | 'event'; effects?: ('blur' | 'grayscale' | 'desaturated' | 'darker')[] | null; caption?: string | null; id?: string | null; @@ -296,22 +283,20 @@ export interface Organisation { | { backgroundColor: 'white' | 'black'; backgroundWidth: 'full' | 'block'; - columns?: - | { - width: 'full' | 'half' | 'oneThird' | 'twoThirds'; - richText: { - [k: string]: unknown; - }[]; - id?: string | null; - }[] - | null; + columns: { + width: 'full' | 'half' | 'oneThird' | 'twoThirds'; + richText: { + [k: string]: unknown; + }[]; + id?: string | null; + }[]; id?: string | null; blockName?: string | null; blockType: 'content'; } | { media: string | Media; - size?: ('normal' | 'wide' | 'event') | null; + size: 'normal' | 'wide' | 'event'; effects?: ('blur' | 'grayscale' | 'desaturated' | 'darker')[] | null; caption?: string | null; id?: string | null; @@ -432,22 +417,20 @@ export interface News { | { backgroundColor: 'white' | 'black'; backgroundWidth: 'full' | 'block'; - columns?: - | { - width: 'full' | 'half' | 'oneThird' | 'twoThirds'; - richText: { - [k: string]: unknown; - }[]; - id?: string | null; - }[] - | null; + columns: { + width: 'full' | 'half' | 'oneThird' | 'twoThirds'; + richText: { + [k: string]: unknown; + }[]; + id?: string | null; + }[]; id?: string | null; blockName?: string | null; blockType: 'content'; } | { media: string | Media; - size?: ('normal' | 'wide' | 'event') | null; + size: 'normal' | 'wide' | 'event'; effects?: ('blur' | 'grayscale' | 'desaturated' | 'darker')[] | null; caption?: string | null; id?: string | null; @@ -551,22 +534,20 @@ export interface Page { | { backgroundColor: 'white' | 'black'; backgroundWidth: 'full' | 'block'; - columns?: - | { - width: 'full' | 'half' | 'oneThird' | 'twoThirds'; - richText: { - [k: string]: unknown; - }[]; - id?: string | null; - }[] - | null; + columns: { + width: 'full' | 'half' | 'oneThird' | 'twoThirds'; + richText: { + [k: string]: unknown; + }[]; + id?: string | null; + }[]; id?: string | null; blockName?: string | null; blockType: 'content'; } | { media: string | Media; - size?: ('normal' | 'wide' | 'event') | null; + size: 'normal' | 'wide' | 'event'; effects?: ('blur' | 'grayscale' | 'desaturated' | 'darker')[] | null; caption?: string | null; id?: string | null; @@ -839,22 +820,20 @@ export interface EventPage { | { backgroundColor: 'white' | 'black'; backgroundWidth: 'full' | 'block'; - columns?: - | { - width: 'full' | 'half' | 'oneThird' | 'twoThirds'; - richText: { - [k: string]: unknown; - }[]; - id?: string | null; - }[] - | null; + columns: { + width: 'full' | 'half' | 'oneThird' | 'twoThirds'; + richText: { + [k: string]: unknown; + }[]; + id?: string | null; + }[]; id?: string | null; blockName?: string | null; blockType: 'content'; } | { media: string | Media; - size?: ('normal' | 'wide' | 'event') | null; + size: 'normal' | 'wide' | 'event'; effects?: ('blur' | 'grayscale' | 'desaturated' | 'darker')[] | null; caption?: string | null; id?: string | null; @@ -956,22 +935,20 @@ export interface EventArchive { | { backgroundColor: 'white' | 'black'; backgroundWidth: 'full' | 'block'; - columns?: - | { - width: 'full' | 'half' | 'oneThird' | 'twoThirds'; - richText: { - [k: string]: unknown; - }[]; - id?: string | null; - }[] - | null; + columns: { + width: 'full' | 'half' | 'oneThird' | 'twoThirds'; + richText: { + [k: string]: unknown; + }[]; + id?: string | null; + }[]; id?: string | null; blockName?: string | null; blockType: 'content'; } | { media: string | Media; - size?: ('normal' | 'wide' | 'event') | null; + size: 'normal' | 'wide' | 'event'; effects?: ('blur' | 'grayscale' | 'desaturated' | 'darker')[] | null; caption?: string | null; id?: string | null;