diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-10-10 19:37:51 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:14:41 +0100 |
| commit | c87c615b5866b8a8f361eeb0764bfdea85740e90 (patch) | |
| tree | c27bda05fd96bbe3154472e170ba1abd5f9ea499 /src | |
| parent | 15522ec9146f6f1956620355c44dea2a6a75b67c (diff) | |
refactor(components): replace Meta component with MetaList
It removes items complexity by allowing consumers to use any label/value
association. Translations should also be defined by the consumer.
Each item can now be configured separately (borders, layout...).
Diffstat (limited to 'src')
51 files changed, 1855 insertions, 910 deletions
diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index 70ac3c9..cb0b7eb 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -4,5 +4,6 @@ export * from './collapsible'; export * from './forms'; export * from './images'; export * from './layout'; +export * from './meta-list'; export * from './nav'; export * from './tooltip'; diff --git a/src/components/molecules/layout/card.fixture.ts b/src/components/molecules/layout/card.fixture.ts deleted file mode 100644 index 01fe2e9..0000000 --- a/src/components/molecules/layout/card.fixture.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const cover = { - alt: 'A picture', - height: 480, - src: 'https://picsum.photos/640/480', - width: 640, -}; - -export const id = 'nam'; - -export const meta = { - author: 'Possimus', - thematics: ['Autem', 'Eos'], -}; - -export const tagline = 'Ut rerum incidunt'; - -export const title = 'Alias qui porro'; - -export const url = '/an-existing-url'; diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss index 7a06508..14a5baf 100644 --- a/src/components/molecules/layout/card.module.scss +++ b/src/components/molecules/layout/card.module.scss @@ -1,5 +1,9 @@ @use "../../../styles/abstracts/functions" as fun; +.footer { + margin-top: auto; +} + .wrapper { --scale-up: 1.05; --scale-down: 0.95; diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx index a9545d1..070c978 100644 --- a/src/components/molecules/layout/card.stories.tsx +++ b/src/components/molecules/layout/card.stories.tsx @@ -1,6 +1,6 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { MetaItemData } from '../meta-list'; import { Card } from './card'; -import { cover, id, meta, tagline, title, url } from './card.fixture'; /** * Card - Storybook Meta @@ -119,6 +119,33 @@ export default { const Template: ComponentStory<typeof Card> = (args) => <Card {...args} />; +const cover = { + alt: 'A picture', + height: 480, + src: 'https://picsum.photos/640/480', + width: 640, +}; + +const id = 'nam'; + +const meta = [ + { id: 'author', label: 'Author', value: 'Possimus' }, + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'autem', value: 'Autem' }, + { id: 'eos', value: 'Eos' }, + ], + }, +] satisfies MetaItemData[]; + +const tagline = 'Ut rerum incidunt'; + +const title = 'Alias qui porro'; + +const url = '/an-existing-url'; + /** * Card Stories - Default */ diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx index c6498b8..b690d4c 100644 --- a/src/components/molecules/layout/card.test.tsx +++ b/src/components/molecules/layout/card.test.tsx @@ -1,37 +1,69 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import type { MetaItemData } from '../meta-list'; import { Card } from './card'; -import { cover, id, meta, tagline, title, url } from './card.fixture'; + +const cover = { + alt: 'A picture', + height: 480, + src: 'https://picsum.photos/640/480', + width: 640, +}; + +const id = 'nam'; + +const meta = [ + { id: 'author', label: 'Author', value: 'Possimus' }, + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'autem', value: 'Autem' }, + { id: 'eos', value: 'Eos' }, + ], + }, +] satisfies MetaItemData[]; + +const tagline = 'Ut rerum incidunt'; + +const title = 'Alias qui porro'; + +const url = '/an-existing-url'; describe('Card', () => { it('renders a title wrapped in h2 element', () => { render(<Card id={id} title={title} titleLevel={2} url={url} />); expect( - screen.getByRole('heading', { level: 2, name: title }) + rtlScreen.getByRole('heading', { level: 2, name: title }) ).toBeInTheDocument(); }); it('renders a link to another page', () => { render(<Card id={id} title={title} titleLevel={2} url={url} />); - expect(screen.getByRole('link')).toHaveAttribute('href', url); + expect(rtlScreen.getByRole('link')).toHaveAttribute('href', url); }); it('renders a cover', () => { render( <Card id={id} title={title} titleLevel={2} url={url} cover={cover} /> ); - expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); + expect(rtlScreen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); }); it('renders a tagline', () => { render( <Card id={id} title={title} titleLevel={2} url={url} tagline={tagline} /> ); - expect(screen.getByText(tagline)).toBeInTheDocument(); + expect(rtlScreen.getByText(tagline)).toBeInTheDocument(); }); it('renders some meta', () => { render(<Card id={id} title={title} titleLevel={2} url={url} meta={meta} />); - expect(screen.getByText(meta.author)).toBeInTheDocument(); + + const metaLabels = meta.map((item) => item.label); + + for (const label of metaLabels) { + expect(rtlScreen.getByText(label)).toBeInTheDocument(); + } }); }); diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx index c316100..d90cba2 100644 --- a/src/components/molecules/layout/card.tsx +++ b/src/components/molecules/layout/card.tsx @@ -1,8 +1,8 @@ import NextImage, { type ImageProps as NextImageProps } from 'next/image'; import type { FC } from 'react'; import { ButtonLink, Figure, Heading, type HeadingLevel } from '../../atoms'; +import { MetaList, type MetaItemData } from '../meta-list'; import styles from './card.module.scss'; -import { Meta, type MetaData } from './meta'; export type CardProps = { /** @@ -20,7 +20,7 @@ export type CardProps = { /** * The card meta. */ - meta?: MetaData; + meta?: MetaItemData[]; /** * The card tagline. */ @@ -73,7 +73,13 @@ export const Card: FC<CardProps> = ({ {tagline ? <div className={styles.tagline}>{tagline}</div> : null} {meta ? ( <footer className={styles.footer}> - <Meta className={styles.list} data={meta} spacing="sm" /> + <MetaList + className={styles.list} + hasBorderedValues={meta.length < 2} + hasInlinedValues={meta.length < 2} + isCentered + items={meta} + /> </footer> ) : null} </article> diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts index e43e664..58d5442 100644 --- a/src/components/molecules/layout/index.ts +++ b/src/components/molecules/layout/index.ts @@ -1,6 +1,5 @@ export * from './card'; export * from './code'; export * from './columns'; -export * from './meta'; export * from './page-footer'; export * from './page-header'; diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss deleted file mode 100644 index 26faac3..0000000 --- a/src/components/molecules/layout/meta.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -.list { - .description:not(:first-of-type) { - &::before { - display: inline; - float: left; - content: "/"; - margin-right: var(--itemSpacing); - } - } - - &--stack { - .term { - flex: 0 0 100%; - } - } -} diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx deleted file mode 100644 index 6faa265..0000000 --- a/src/components/molecules/layout/meta.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Meta as MetaComponent, type MetaData } from './meta'; - -/** - * Meta - Storybook Meta - */ -export default { - title: 'Molecules/Layout', - component: MetaComponent, - args: {}, - argTypes: { - data: { - description: 'The page metadata.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - }, -} as ComponentMeta<typeof MetaComponent>; - -const Template: ComponentStory<typeof MetaComponent> = (args) => ( - <MetaComponent {...args} /> -); - -const data: MetaData = { - publication: { date: '2022-04-09', time: '01:04:00' }, - thematics: [ - <a key="category1" href="#a"> - Category 1 - </a>, - <a key="category2" href="#b"> - Category 2 - </a>, - ], -}; - -/** - * Layout Stories - Meta - */ -export const Meta = Template.bind({}); -Meta.args = { - data, -}; diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx deleted file mode 100644 index 0635fc3..0000000 --- a/src/components/molecules/layout/meta.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { getFormattedDate } from '../../../utils/helpers'; -import { Meta } from './meta'; - -const data = { - publication: { date: '2022-04-09' }, - thematics: [ - <a key="category1" href="#a"> - Category 1 - </a>, - <a key="category2" href="#b"> - Category 2 - </a>, - ], -}; - -describe('Meta', () => { - it('format a date string', () => { - render(<Meta data={data} />); - expect( - rtlScreen.getByText(getFormattedDate(data.publication.date)) - ).toBeInTheDocument(); - }); -}); diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx deleted file mode 100644 index 63909a4..0000000 --- a/src/components/molecules/layout/meta.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import type { FC, ReactNode } from 'react'; -import { useIntl } from 'react-intl'; -import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; -import { - DescriptionList, - type DescriptionListProps, - Link, - Group, - Term, - Description, -} from '../../atoms'; -import styles from './meta.module.scss'; - -export type CustomMeta = { - label: string; - value: ReactNode; -}; - -export type MetaComments = { - /** - * A page title. - */ - about: string; - /** - * The comments count. - */ - count: number; - /** - * Wrap the comments count with a link to the given target. - */ - target?: string; -}; - -export type MetaDate = { - /** - * A date string. Ex: `2022-04-30`. - */ - date: string; - /** - * A time string. Ex: `10:25:59`. - */ - time?: string; - /** - * Wrap the date with a link to the given target. - */ - target?: string; -}; - -export type MetaData = { - /** - * The author name. - */ - author?: string; - /** - * The comments count. - */ - comments?: MetaComments; - /** - * The creation date. - */ - creation?: MetaDate; - /** - * A custom label/value metadata. - */ - custom?: CustomMeta; - /** - * The license name. - */ - license?: string; - /** - * The popularity. - */ - popularity?: string | JSX.Element; - /** - * The publication date. - */ - publication?: MetaDate; - /** - * The estimated reading time. - */ - readingTime?: string | JSX.Element; - /** - * An array of repositories. - */ - repositories?: string[] | JSX.Element[]; - /** - * An array of technologies. - */ - technologies?: string[]; - /** - * An array of thematics. - */ - thematics?: string[] | JSX.Element[]; - /** - * An array of thematics. - */ - topics?: string[] | JSX.Element[]; - /** - * A total number of posts. - */ - total?: number; - /** - * The update date. - */ - update?: MetaDate; - /** - * An url. - */ - website?: string; -}; - -const isCustomMeta = ( - key: keyof MetaData, - _value: unknown -): _value is MetaData['custom'] => key === 'custom'; - -export type MetaProps = Omit<DescriptionListProps, 'children'> & { - /** - * The meta data. - */ - data: MetaData; -}; - -/** - * Meta component - * - * Renders the given metadata. - */ -export const Meta: FC<MetaProps> = ({ - className = '', - data, - isInline = false, - ...props -}) => { - const layoutClass = styles[isInline ? 'list--inline' : 'list--stack']; - const listClass = `${styles.list} ${layoutClass} ${className}`; - const intl = useIntl(); - - /** - * Retrieve the item label based on its key. - * - * @param {keyof MetaData} key - The meta key. - * @returns {string} The item label. - */ - const getLabel = (key: keyof MetaData): string => { - switch (key) { - case 'author': - return intl.formatMessage({ - defaultMessage: 'Written by:', - description: 'Meta: author label', - id: 'OI0N37', - }); - case 'comments': - return intl.formatMessage({ - defaultMessage: 'Comments:', - description: 'Meta: comments label', - id: 'jTVIh8', - }); - case 'creation': - return intl.formatMessage({ - defaultMessage: 'Created on:', - description: 'Meta: creation date label', - id: 'b4fdYE', - }); - case 'license': - return intl.formatMessage({ - defaultMessage: 'License:', - description: 'Meta: license label', - id: 'AuGklx', - }); - case 'popularity': - return intl.formatMessage({ - defaultMessage: 'Popularity:', - description: 'Meta: popularity label', - id: 'pWTj2W', - }); - case 'publication': - return intl.formatMessage({ - defaultMessage: 'Published on:', - description: 'Meta: publication date label', - id: 'QGi5uD', - }); - case 'readingTime': - return intl.formatMessage({ - defaultMessage: 'Reading time:', - description: 'Meta: reading time label', - id: 'EbFvsM', - }); - case 'repositories': - return intl.formatMessage({ - defaultMessage: 'Repositories:', - description: 'Meta: repositories label', - id: 'DssFG1', - }); - case 'technologies': - return intl.formatMessage({ - defaultMessage: 'Technologies:', - description: 'Meta: technologies label', - id: 'ADQmDF', - }); - case 'thematics': - return intl.formatMessage({ - defaultMessage: 'Thematics:', - description: 'Meta: thematics label', - id: 'bz53Us', - }); - case 'topics': - return intl.formatMessage({ - defaultMessage: 'Topics:', - description: 'Meta: topics label', - id: 'gJNaBD', - }); - case 'total': - return intl.formatMessage({ - defaultMessage: 'Total:', - description: 'Meta: total label', - id: '92zgdp', - }); - case 'update': - return intl.formatMessage({ - defaultMessage: 'Updated on:', - description: 'Meta: update date label', - id: 'tLC7bh', - }); - case 'website': - return intl.formatMessage({ - defaultMessage: 'Official website:', - description: 'Meta: official website label', - id: 'GRyyfy', - }); - default: - return ''; - } - }; - - /** - * Retrieve a formatted date (and time). - * - * @param {MetaDate} dateTime - A date object. - * @returns {JSX.Element} The formatted date wrapped in a time element. - */ - const getDate = (dateTime: MetaDate): JSX.Element => { - const { date, time, target } = dateTime; - - if (!dateTime.time) { - const isoDate = new Date(`${date}`).toISOString(); - return target ? ( - <Link href={target}> - <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time> - </Link> - ) : ( - <time dateTime={isoDate}>{getFormattedDate(dateTime.date)}</time> - ); - } - - const isoDateTime = new Date(`${date}T${time}`).toISOString(); - const dateString = intl.formatMessage( - { - defaultMessage: '{date} at {time}', - description: 'Meta: publication date and time', - id: 'fcHeyC', - }, - { - date: getFormattedDate(dateTime.date), - time: getFormattedTime(`${dateTime.date}T${dateTime.time}`), - } - ); - - return target ? ( - <Link href={target}> - <time dateTime={isoDateTime}>{dateString}</time> - </Link> - ) : ( - <time dateTime={isoDateTime}>{dateString}</time> - ); - }; - - /** - * Retrieve the formatted comments count. - * - * @param comments - The comments object. - * @returns {string | JSX.Element} - The comments count. - */ - const getCommentsCount = (comments: MetaComments): string | JSX.Element => { - const { about, count, target } = comments; - const commentsCount = intl.formatMessage( - { - defaultMessage: - '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>', - description: 'Meta: comments count', - id: '02rgLO', - }, - { - a11y: (chunks: ReactNode) => ( - <span className="screen-reader-text">{chunks}</span> - ), - commentsCount: count, - title: about, - } - ); - - return target ? ( - <Link href={target}>{commentsCount as JSX.Element}</Link> - ) : ( - (commentsCount as JSX.Element) - ); - }; - - /** - * Retrieve the formatted item value. - * - * @param {keyof MetaData} key - The meta key. - * @param {ValueOf<MetaData>} value - The meta value. - * @returns {string|ReactNode|ReactNode[]} - The formatted value. - */ - const getValue = <T extends keyof MetaData>( - key: T, - value: MetaData[T] - ): string | ReactNode | ReactNode[] => { - switch (key) { - case 'comments': - return getCommentsCount(value as MetaComments); - case 'creation': - case 'publication': - case 'update': - return getDate(value as MetaDate); - case 'total': - return intl.formatMessage( - { - defaultMessage: - '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', - description: 'BlogPage: posts count meta', - id: 'OF5cPz', - }, - { postsCount: value as number } - ); - case 'website': - return typeof value === 'string' ? ( - <Link href={value} isExternal> - {value} - </Link> - ) : null; - default: - return value as string | ReactNode | ReactNode[]; - } - }; - - /** - * Transform the metadata to description list item format. - * - * @param {MetaData} items - The meta. - * @returns {DescriptionListItem[]} The formatted description list items. - */ - const getItems = (items: MetaData) => { - const entries = Object.entries(items) as [ - keyof MetaData, - MetaData[keyof MetaData], - ][]; - const listItems = entries.map(([key, meta]) => { - if (!meta) return null; - - return ( - <Group isInline key={key} spacing="2xs"> - <Term className={styles.term}> - {isCustomMeta(key, meta) ? meta.label : getLabel(key)} - </Term> - {Array.isArray(meta) ? ( - meta.map((singleMeta, index) => ( - /* eslint-disable-next-line react/no-array-index-key -- Unsafe, - * but also temporary. This component should be removed or - * refactored. */ - <Description className={styles.description} key={index}> - {isCustomMeta(key, singleMeta) - ? singleMeta - : getValue(key, singleMeta)} - </Description> - )) - ) : ( - <Description className={styles.description}> - {isCustomMeta(key, meta) ? meta.value : getValue(key, meta)} - </Description> - )} - </Group> - ); - }); - - return listItems; - }; - - return ( - <DescriptionList {...props} className={listClass} isInline={isInline}> - {getItems(data)} - </DescriptionList> - ); -}; diff --git a/src/components/molecules/layout/page-footer.stories.tsx b/src/components/molecules/layout/page-footer.stories.tsx index 8e991a4..48c8c17 100644 --- a/src/components/molecules/layout/page-footer.stories.tsx +++ b/src/components/molecules/layout/page-footer.stories.tsx @@ -1,5 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { MetaData } from './meta'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { PageFooter as PageFooterComponent } from './page-footer'; /** @@ -40,16 +39,17 @@ const Template: ComponentStory<typeof PageFooterComponent> = (args) => ( <PageFooterComponent {...args} /> ); -const meta: MetaData = { - custom: { +const meta = [ + { + id: 'more-about', label: 'More posts about:', - value: [ - <a key="topic-1" href="#"> + value: ( + <a key="topic-1" href="#topic1"> Topic name - </a>, - ], + </a> + ), }, -}; +]; /** * Page Footer Stories - With meta diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx index 375cbc4..a93fced 100644 --- a/src/components/molecules/layout/page-footer.tsx +++ b/src/components/molecules/layout/page-footer.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react'; import { Footer, type FooterProps } from '../../atoms'; -import { Meta, type MetaData } from './meta'; +import { MetaList, type MetaItemData } from '../meta-list'; export type PageFooterProps = Omit<FooterProps, 'children'> & { /** * The footer metadata. */ - meta?: MetaData; + meta?: MetaItemData[]; }; /** @@ -15,5 +15,7 @@ export type PageFooterProps = Omit<FooterProps, 'children'> & { * Render a footer to display page meta. */ export const PageFooter: FC<PageFooterProps> = ({ meta, ...props }) => ( - <Footer {...props}>{meta ? <Meta data={meta} /> : null}</Footer> + <Footer {...props}> + {meta ? <MetaList hasInlinedValues items={meta} /> : null} + </Footer> ); diff --git a/src/components/molecules/layout/page-header.stories.tsx b/src/components/molecules/layout/page-header.stories.tsx index ea943bf..54d5fe8 100644 --- a/src/components/molecules/layout/page-header.stories.tsx +++ b/src/components/molecules/layout/page-header.stories.tsx @@ -1,4 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { PageHeader } from './page-header'; /** @@ -62,17 +62,31 @@ const Template: ComponentStory<typeof PageHeader> = (args) => ( <PageHeader {...args} /> ); -const meta = { - publication: { date: '2022-04-09' }, - thematics: [ - <a key="category1" href="#"> - Category 1 - </a>, - <a key="category2" href="#"> - Category 2 - </a>, - ], -}; +const meta = [ + { id: 'publication-date', label: 'Published on:', value: '2022-04-09' }, + { + id: 'thematics', + label: 'Thematics:', + value: [ + { + id: 'cat-1', + value: ( + <a key="category1" href="#cat1"> + Category 1 + </a> + ), + }, + { + id: 'cat-2', + value: ( + <a key="category2" href="#cat2"> + Category 2 + </a> + ), + }, + ], + }, +]; /** * Page Header Stories - Default diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx index b727cc1..ea0dd2c 100644 --- a/src/components/molecules/layout/page-header.tsx +++ b/src/components/molecules/layout/page-header.tsx @@ -1,6 +1,6 @@ import type { FC, ReactNode } from 'react'; import { Header, Heading } from '../../atoms'; -import { Meta, type MetaData } from './meta'; +import { MetaList, type MetaItemData } from '../meta-list'; import styles from './page-header.module.scss'; export type PageHeaderProps = { @@ -15,7 +15,7 @@ export type PageHeaderProps = { /** * The page metadata. */ - meta?: MetaData; + meta?: MetaItemData[]; /** * The page title. */ @@ -56,7 +56,7 @@ export const PageHeader: FC<PageHeaderProps> = ({ {title} </Heading> {meta ? ( - <Meta className={styles.meta} data={meta} isInline spacing="xs" /> + <MetaList className={styles.meta} hasInlinedItems items={meta} /> ) : null} {intro ? getIntro() : null} </div> diff --git a/src/components/molecules/meta-list/index.ts b/src/components/molecules/meta-list/index.ts new file mode 100644 index 0000000..93f437d --- /dev/null +++ b/src/components/molecules/meta-list/index.ts @@ -0,0 +1,2 @@ +export * from './meta-item'; +export * from './meta-list'; diff --git a/src/components/molecules/meta-list/meta-item/index.ts b/src/components/molecules/meta-list/meta-item/index.ts new file mode 100644 index 0000000..47795de --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/index.ts @@ -0,0 +1 @@ +export * from './meta-item'; diff --git a/src/components/molecules/meta-list/meta-item/meta-item.module.scss b/src/components/molecules/meta-list/meta-item/meta-item.module.scss new file mode 100644 index 0000000..a1c2d47 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.module.scss @@ -0,0 +1,62 @@ +@use "../../../../styles/abstracts/functions" as fun; + +.item { + column-gap: var(--spacing-2xs); + align-content: baseline; + + &--bordered-values { + row-gap: var(--spacing-2xs); + } + + &--centered { + margin-inline: auto; + text-align: center; + place-items: center; + justify-content: center; + } + + &--inlined { + align-items: first baseline; + } + + &--inlined-values { + flex-flow: row wrap; + } + + &:not(#{&}--bordered-values) { + row-gap: fun.convert-px(3); + } +} + +.value { + width: fit-content; + height: fit-content; + color: var(--color-fg); + font-weight: 400; +} + +:where(.item--bordered-values) { + .value { + padding: fun.convert-px(2) var(--spacing-2xs); + border: fun.convert-px(1) solid var(--color-primary-darker); + } +} + +:where(.item--inlined-values) { + .label { + flex: 1 0 100%; + } +} + +/* It's an arbitrary choice. When there is only one meta item (like on small + * cards) removing the width can mess up the layout. However, must of the times + * when there are multiples items, we need to remove the width especially if we + * want to use `isCentered` prop. */ +:where(.item--inlined-values:not(:only-of-type)) { + .label { + /* We need to remove its width to avoid an extra space and make the + * container width fit its contents. However the label should be smaller + * than the values to avoid unexpected behavior with layout. */ + width: 0; + } +} diff --git a/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx new file mode 100644 index 0000000..3ddb8f1 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx @@ -0,0 +1,108 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Link } from '../../../atoms'; +import { MetaItem } from './meta-item'; + +/** + * MetaItem - Storybook Meta + */ +export default { + title: 'Molecules/MetaList/Item', + component: MetaItem, + argTypes: { + label: { + control: { + type: 'text', + }, + description: 'The item label.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof MetaItem>; + +const Template: ComponentStory<typeof MetaItem> = (args) => ( + <MetaItem {...args} /> +); + +/** + * MetaItem Stories - SingleValue + */ +export const SingleValue = Template.bind({}); +SingleValue.args = { + label: 'Comments', + value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValues + */ +export const MultipleValues = Template.bind({}); +MultipleValues.args = { + label: 'Tags', + value: [ + { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, + { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, + ], +}; + +/** + * MetaItem Stories - SingleValueBordered + */ +export const SingleValueBordered = Template.bind({}); +SingleValueBordered.args = { + hasBorderedValues: true, + label: 'Comments', + value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValuesBordered + */ +export const MultipleValuesBordered = Template.bind({}); +MultipleValuesBordered.args = { + hasBorderedValues: true, + label: 'Tags', + value: [ + { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, + { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, + ], +}; + +/** + * MetaItem Stories - SingleValueInlined + */ +export const SingleValueInlined = Template.bind({}); +SingleValueInlined.args = { + isInline: true, + label: 'Comments', + value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValuesInlined + */ +export const MultipleValuesInlined = Template.bind({}); +MultipleValuesInlined.args = { + isInline: true, + label: 'Tags', + value: [ + { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, + { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, + ], +}; + +/** + * MetaItem Stories - InlinedValues + */ +export const InlinedValues = Template.bind({}); +InlinedValues.args = { + hasInlinedValues: true, + label: 'Tags', + value: [ + { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, + { id: 'tag2', value: <Link href="#tag2">A long tag 2</Link> }, + { id: 'tag3', value: <Link href="#tag3">Tag 3</Link> }, + ], +}; diff --git a/src/components/molecules/meta-list/meta-item/meta-item.test.tsx b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx new file mode 100644 index 0000000..629c4b2 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { MetaItem } from './meta-item'; + +describe('MetaItem', () => { + it('renders a label and a value', () => { + const label = 'iusto'; + const value = 'autem'; + + render( + <dl> + <MetaItem label={label} value={value} /> + </dl> + ); + + expect(rtlScreen.getByRole('term')).toHaveTextContent(label); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(value); + }); + + it('can render a label with multiple values', () => { + const label = 'iusto'; + const values = [ + { id: 'autem', value: 'autem' }, + { id: 'quisquam', value: 'aut' }, + { id: 'molestias', value: 'voluptatem' }, + ]; + + render( + <dl> + <MetaItem label={label} value={values} /> + </dl> + ); + + expect(rtlScreen.getByRole('term')).toHaveTextContent(label); + expect(rtlScreen.getAllByRole('definition')).toHaveLength(values.length); + }); + + it('can render a centered group of label and values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( + <dl> + <MetaItem isCentered label={label} value={value} /> + </dl> + ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--centered' + ); + }); + + it('can render an inlined group of label and values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( + <dl> + <MetaItem isInline label={label} value={value} /> + </dl> + ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--inlined' + ); + }); + + it('can render a group of label and bordered values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( + <dl> + <MetaItem hasBorderedValues label={label} value={value} /> + </dl> + ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--bordered-values' + ); + }); + + it('can render a group of label and inlined values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( + <dl> + <MetaItem hasInlinedValues label={label} value={value} /> + </dl> + ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--inlined-values' + ); + }); +}); diff --git a/src/components/molecules/meta-list/meta-item/meta-item.tsx b/src/components/molecules/meta-list/meta-item/meta-item.tsx new file mode 100644 index 0000000..c5223c2 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.tsx @@ -0,0 +1,90 @@ +import { + type ForwardRefRenderFunction, + type ReactElement, + type ReactNode, + forwardRef, +} from 'react'; +import { Description, Group, type GroupProps, Term } from '../../../atoms'; +import styles from './meta-item.module.scss'; + +export type MetaValue = string | ReactElement; + +export type MetaValues = { + id: string; + value: MetaValue; +}; + +export type MetaItemProps = Omit<GroupProps, 'children' | 'spacing'> & { + /** + * Should the values be bordered? + * + * @default false + */ + hasBorderedValues?: boolean; + /** + * Should the values be inlined? + * + * @warning If you use it make sure the value is larger than the label. It + * could mess up your design since we are removing the label width. + * + * @default false + */ + hasInlinedValues?: boolean; + /** + * Should the label and values be centered? + * + * @default false + */ + isCentered?: boolean; + /** + * The item label. + */ + label: ReactNode; + /** + * The item value or values. + */ + value: MetaValue | MetaValues[]; +}; + +const MetaItemWithRef: ForwardRefRenderFunction< + HTMLDivElement, + MetaItemProps +> = ( + { + className = '', + hasBorderedValues = false, + hasInlinedValues = false, + isCentered = false, + isInline = false, + label, + value, + ...props + }, + ref +) => { + const itemClass = [ + styles.item, + styles[hasBorderedValues ? 'item--bordered-values' : ''], + styles[hasInlinedValues ? 'item--inlined-values' : ''], + styles[isCentered ? 'item--centered' : ''], + styles[isInline ? 'item--inlined' : 'item--stacked'], + className, + ].join(' '); + + return ( + <Group {...props} className={itemClass} isInline={isInline} ref={ref}> + <Term className={styles.label}>{label}</Term> + {Array.isArray(value) ? ( + value.map((item) => ( + <Description className={styles.value} key={item.id}> + {item.value} + </Description> + )) + ) : ( + <Description className={styles.value}>{value}</Description> + )} + </Group> + ); +}; + +export const MetaItem = forwardRef(MetaItemWithRef); diff --git a/src/components/molecules/meta-list/meta-list.module.scss b/src/components/molecules/meta-list/meta-list.module.scss new file mode 100644 index 0000000..5570f4c --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.module.scss @@ -0,0 +1,24 @@ +.list { + display: grid; + width: fit-content; + height: fit-content; + + &--centered { + margin-inline: auto; + justify-items: center; + } + + &--inlined { + grid-auto-flow: column; + grid-template-columns: repeat( + auto-fit, + min(calc(100vw - (var(--spacing-md) * 2)), 1fr) + ); + column-gap: clamp(var(--spacing-lg), 3vw, var(--spacing-3xl)); + row-gap: clamp(var(--spacing-sm), 3vw, var(--spacing-md)); + } + + &--stacked { + gap: var(--spacing-2xs); + } +} diff --git a/src/components/molecules/meta-list/meta-list.stories.tsx b/src/components/molecules/meta-list/meta-list.stories.tsx new file mode 100644 index 0000000..463ec96 --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.stories.tsx @@ -0,0 +1,70 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Link } from '../../atoms'; +import { type MetaItemData, MetaList } from './meta-list'; + +/** + * MetaList - Storybook Meta + */ +export default { + title: 'Molecules/MetaList', + component: MetaList, + argTypes: { + items: { + description: 'The meta items.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }, +} as ComponentMeta<typeof MetaList>; + +const Template: ComponentStory<typeof MetaList> = (args) => ( + <MetaList {...args} /> +); + +const items: MetaItemData[] = [ + { id: 'comments', label: 'Comments', value: 'No comments.' }, + { + id: 'category', + label: 'Category', + value: <Link href="#cat1">Cat 1</Link>, + }, + { + id: 'tags', + label: 'Tags', + value: [ + { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> }, + { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> }, + ], + }, + { + hasBorderedValues: true, + hasInlinedValues: true, + id: 'technologies', + label: 'Technologies', + value: [ + { id: 'techno1', value: 'HTML' }, + { id: 'techno2', value: 'CSS' }, + { id: 'techno3', value: 'Javascript' }, + ], + }, +]; + +/** + * MetaList Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + items, +}; + +/** + * MetaList Stories - Inlined + */ +export const Inlined = Template.bind({}); +Inlined.args = { + isInline: true, + items, +}; diff --git a/src/components/molecules/meta-list/meta-list.test.tsx b/src/components/molecules/meta-list/meta-list.test.tsx new file mode 100644 index 0000000..cc4d2fa --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { type MetaItemData, MetaList } from './meta-list'; + +describe('MetaList', () => { + it('renders a list of meta items', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(<MetaList items={items} />); + + expect(rtlScreen.getAllByRole('term')).toHaveLength(items.length); + expect(rtlScreen.getAllByRole('definition')).toHaveLength(items.length); + }); + + it('can render a centered list of meta items', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(<MetaList isCentered items={items} />); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement?.parentElement).toHaveClass('list--centered'); + }); + + it('can render an inlined list of meta items', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(<MetaList isInline items={items} />); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement?.parentElement).toHaveClass('list--inlined'); + }); + + it('can render a list of meta items with bordered values', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(<MetaList hasBorderedValues items={items} />); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement).toHaveClass('item--bordered-values'); + }); + + it('can render a list of meta items with inlined values', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(<MetaList hasInlinedValues items={items} />); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement).toHaveClass('item--inlined-values'); + }); +}); diff --git a/src/components/molecules/meta-list/meta-list.tsx b/src/components/molecules/meta-list/meta-list.tsx new file mode 100644 index 0000000..288fd9a --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.tsx @@ -0,0 +1,78 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { DescriptionList, type DescriptionListProps } from '../../atoms'; +import { MetaItem, type MetaItemProps } from './meta-item'; +import styles from './meta-list.module.scss'; + +export type MetaItemData = Pick< + MetaItemProps, + | 'hasBorderedValues' + | 'hasInlinedValues' + | 'isCentered' + | 'isInline' + | 'label' + | 'value' +> & { + id: string; +}; + +export type MetaListProps = Omit<DescriptionListProps, 'children' | 'spacing'> & + Pick<MetaItemProps, 'hasBorderedValues' | 'hasInlinedValues'> & { + /** + * Should the items be inlined? + * + * @default false + */ + hasInlinedItems?: boolean; + /** + * Should the meta be centered? + * + * @default false + */ + isCentered?: boolean; + /** + * The meta items. + */ + items: MetaItemData[]; + }; + +const MetaListWithRef: ForwardRefRenderFunction< + HTMLDListElement, + MetaListProps +> = ( + { + className = '', + hasBorderedValues = false, + hasInlinedItems = false, + hasInlinedValues = false, + isCentered = false, + isInline = false, + items, + ...props + }, + ref +) => { + const listClass = [ + styles.list, + styles[isCentered ? 'list--centered' : ''], + styles[isInline ? 'list--inlined' : 'list--stacked'], + className, + ].join(' '); + + return ( + <DescriptionList {...props} className={listClass} ref={ref}> + {items.map(({ id, ...item }) => ( + <MetaItem + hasBorderedValues={hasBorderedValues} + hasInlinedValues={hasInlinedValues} + isCentered={isCentered} + isInline={hasInlinedItems} + // Each item should be able to override the global settings. + {...item} + key={id} + /> + ))} + </DescriptionList> + ); +}; + +export const MetaList = forwardRef(MetaListWithRef); diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx index 1b5051f..03feee7 100644 --- a/src/components/organisms/layout/cards-list.stories.tsx +++ b/src/components/organisms/layout/cards-list.stories.tsx @@ -90,11 +90,21 @@ const items: CardsListItem[] = [ id: 'card-1', cover: { alt: 'card 1 picture', - src: 'http://picsum.photos/640/480', + src: 'https://picsum.photos/640/480', width: 640, height: 480, }, - meta: { thematics: ['Velit', 'Ex', 'Alias'] }, + meta: [ + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'velit', value: 'Velit' }, + { id: 'ex', value: 'Ex' }, + { id: 'alias', value: 'Alias' }, + ], + }, + ], tagline: 'Molestias ut error', title: 'Et alias omnis', url: '#', @@ -103,11 +113,11 @@ const items: CardsListItem[] = [ id: 'card-2', cover: { alt: 'card 2 picture', - src: 'http://picsum.photos/640/480', + src: 'https://picsum.photos/640/480', width: 640, height: 480, }, - meta: { thematics: ['Voluptas'] }, + meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }], tagline: 'Quod vel accusamus', title: 'Laboriosam doloremque mollitia', url: '#', @@ -116,13 +126,22 @@ const items: CardsListItem[] = [ id: 'card-3', cover: { alt: 'card 3 picture', - src: 'http://picsum.photos/640/480', + src: 'https://picsum.photos/640/480', width: 640, height: 480, }, - meta: { - thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], - }, + meta: [ + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'quisquam', value: 'Quisquam' }, + { id: 'quia', value: 'Quia' }, + { id: 'sapiente', value: 'Sapiente' }, + { id: 'perspiciatis', value: 'Perspiciatis' }, + ], + }, + ], tagline: 'Quo error eum', title: 'Magni rem nulla', url: '#', diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx index 751a502..c9d6ae7 100644 --- a/src/components/organisms/layout/cards-list.test.tsx +++ b/src/components/organisms/layout/cards-list.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; import { CardsList, type CardsListItem } from './cards-list'; const items: CardsListItem[] = [ @@ -7,11 +7,21 @@ const items: CardsListItem[] = [ id: 'card-1', cover: { alt: 'card 1 picture', - src: 'http://placeimg.com/640/480', + src: 'https://picsum.photos/640/480', width: 640, height: 480, }, - meta: { thematics: ['Velit', 'Ex', 'Alias'] }, + meta: [ + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'velit', value: 'Velit' }, + { id: 'ex', value: 'Ex' }, + { id: 'alias', value: 'Alias' }, + ], + }, + ], tagline: 'Molestias ut error', title: 'Et alias omnis', url: '#', @@ -20,11 +30,11 @@ const items: CardsListItem[] = [ id: 'card-2', cover: { alt: 'card 2 picture', - src: 'http://placeimg.com/640/480', + src: 'https://picsum.photos/640/480', width: 640, height: 480, }, - meta: { thematics: ['Voluptas'] }, + meta: [{ id: 'categories', label: 'Categories', value: 'Voluptas' }], tagline: 'Quod vel accusamus', title: 'Laboriosam doloremque mollitia', url: '#', @@ -33,13 +43,22 @@ const items: CardsListItem[] = [ id: 'card-3', cover: { alt: 'card 3 picture', - src: 'http://placeimg.com/640/480', + src: 'https://picsum.photos/640/480', width: 640, height: 480, }, - meta: { - thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], - }, + meta: [ + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'quisquam', value: 'Quisquam' }, + { id: 'quia', value: 'Quia' }, + { id: 'sapiente', value: 'Sapiente' }, + { id: 'perspiciatis', value: 'Perspiciatis' }, + ], + }, + ], tagline: 'Quo error eum', title: 'Magni rem nulla', url: '#', @@ -49,7 +68,7 @@ const items: CardsListItem[] = [ describe('CardsList', () => { it('renders a list of cards', () => { render(<CardsList items={items} titleLevel={2} />); - expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength( + expect(rtlScreen.getAllByRole('heading', { level: 2 })).toHaveLength( items.length ); }); diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx index ca209f5..e1ea6b5 100644 --- a/src/components/organisms/layout/comment.tsx +++ b/src/components/organisms/layout/comment.tsx @@ -5,9 +5,10 @@ import { type FC, useCallback, useState } from 'react'; import { useIntl } from 'react-intl'; import type { Comment as CommentSchema, WithContext } from 'schema-dts'; import type { SingleComment } from '../../../types'; +import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; import { useSettings } from '../../../utils/hooks'; import { Button, Link } from '../../atoms'; -import { Meta } from '../../molecules'; +import { MetaList } from '../../molecules'; import { CommentForm, type CommentFormProps } from '../forms'; import styles from './comment.module.scss'; @@ -61,6 +62,20 @@ export const UserComment: FC<UserCommentProps> = ({ const { author, date } = meta; const [publicationDate, publicationTime] = date.split(' '); + const isoDateTime = new Date( + `${publicationDate}T${publicationTime}` + ).toISOString(); + const commentDate = intl.formatMessage( + { + defaultMessage: '{date} at {time}', + description: 'Comment: publication date and time', + id: 'Ld6yMP', + }, + { + date: getFormattedDate(publicationDate), + time: getFormattedTime(`${publicationDate}T${publicationTime}`), + } + ); const buttonLabel = isReplying ? intl.formatMessage({ @@ -135,16 +150,24 @@ export const UserComment: FC<UserCommentProps> = ({ <span className={styles.author}>{author.name}</span> )} </header> - <Meta + <MetaList className={styles.date} - data={{ - publication: { - date: publicationDate, - time: publicationTime, - target: `#comment-${id}`, - }, - }} isInline + items={[ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'Comment: publication date label', + id: 'soj7do', + }), + value: ( + <Link href={`#comment-${id}`}> + <time dateTime={isoDateTime}>{commentDate}</time> + </Link> + ), + }, + ]} /> <div className={styles.body} diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss index 59ce167..c1d9463 100644 --- a/src/components/organisms/layout/overview.module.scss +++ b/src/components/organisms/layout/overview.module.scss @@ -11,7 +11,7 @@ auto-fit, min(calc(100vw - (var(--spacing-md) * 2)), 23ch) ); - row-gap: var(--spacing-2xs); + row-gap: var(--spacing-sm); @include mix.media("screen") { @include mix.dimensions("md") { @@ -21,21 +21,6 @@ ); } } - - &--has-techno { - div:last-child { - gap: var(--spacing-2xs); - - dd { - padding: 0 var(--spacing-2xs); - border: fun.convert-px(1) solid var(--color-border-dark); - - &::before { - display: none; - } - } - } - } } .cover { diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx index 8f56d3a..562d7c4 100644 --- a/src/components/organisms/layout/overview.stories.tsx +++ b/src/components/organisms/layout/overview.stories.tsx @@ -1,5 +1,6 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Overview, type OverviewMeta } from './overview'; +import type { MetaItemData } from '../../molecules'; +import { Overview } from './overview'; /** * Overview - Storybook Meta @@ -54,10 +55,10 @@ const cover = { width: 640, }; -const meta: OverviewMeta = { - creation: { date: '2022-05-09' }, - license: 'Dignissimos ratione veritatis', -}; +const meta = [ + { id: 'creation-date', label: 'Creation date', value: '2022-05-09' }, + { id: 'license', label: 'License', value: 'Dignissimos ratione veritatis' }, +] satisfies MetaItemData[]; /** * Overview Stories - Default diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx index 0f2af7b..b98bd6f 100644 --- a/src/components/organisms/layout/overview.test.tsx +++ b/src/components/organisms/layout/overview.test.tsx @@ -1,27 +1,33 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Overview, type OverviewMeta } from './overview'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import type { MetaItemData } from '../../molecules'; +import { Overview } from './overview'; const cover = { alt: 'Incidunt unde quam', height: 480, - src: 'http://placeimg.com/640/480/cats', + src: 'https://picsum.photos/640/480', width: 640, }; -const data: OverviewMeta = { - creation: { date: '2022-05-09' }, - license: 'Dignissimos ratione veritatis', -}; +const meta = [ + { id: 'creation-date', label: 'Creation date', value: '2022-05-09' }, + { id: 'license', label: 'License', value: 'Dignissimos ratione veritatis' }, +] satisfies MetaItemData[]; describe('Overview', () => { - it('renders some data', () => { - render(<Overview meta={data} />); - expect(screen.getByText(data.license!)).toBeInTheDocument(); + it('renders some meta', () => { + render(<Overview meta={meta} />); + + const metaLabels = meta.map((item) => item.label); + + for (const label of metaLabels) { + expect(rtlScreen.getByText(label)).toBeInTheDocument(); + } }); it('renders a cover', () => { - render(<Overview cover={cover} meta={data} />); - expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); + render(<Overview cover={cover} meta={meta} />); + expect(rtlScreen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); }); }); diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx index 8af58ec..ede2627 100644 --- a/src/components/organisms/layout/overview.tsx +++ b/src/components/organisms/layout/overview.tsx @@ -1,19 +1,9 @@ import NextImage, { type ImageProps as NextImageProps } from 'next/image'; import type { FC } from 'react'; import { Figure } from '../../atoms'; -import { Meta, type MetaData } from '../../molecules'; +import { MetaList, type MetaItemData } from '../../molecules'; import styles from './overview.module.scss'; -export type OverviewMeta = Pick< - MetaData, - | 'creation' - | 'license' - | 'popularity' - | 'repositories' - | 'technologies' - | 'update' ->; - export type OverviewProps = { /** * Set additional classnames to the overview wrapper. @@ -26,7 +16,7 @@ export type OverviewProps = { /** * The overview meta. */ - meta: OverviewMeta; + meta: MetaItemData[]; }; /** @@ -39,20 +29,16 @@ export const Overview: FC<OverviewProps> = ({ cover, meta, }) => { - const { technologies, ...remainingMeta } = meta; - const metaModifier = technologies ? styles['meta--has-techno'] : ''; + const wrapperClass = `${styles.wrapper} ${className}`; return ( - <div className={`${styles.wrapper} ${className}`}> + <div className={wrapperClass}> {cover ? ( <Figure> <NextImage {...cover} className={styles.cover} /> </Figure> ) : null} - <Meta - className={`${styles.meta} ${metaModifier}`} - data={{ ...remainingMeta, technologies }} - /> + <MetaList className={styles.meta} hasInlinedValues items={meta} /> </div> ); }; diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss index 9dc1a69..ffc30ac 100644 --- a/src/components/organisms/layout/summary.module.scss +++ b/src/components/organisms/layout/summary.module.scss @@ -109,13 +109,9 @@ flex-flow: row wrap; font-size: var(--font-size-sm); - &__item { - flex: 1 0 min(calc(100vw - 2 * var(--spacing-md)), 14ch); - } - @include mix.media("screen") { @include mix.dimensions("sm") { - display: flex; + flex-flow: column wrap; margin-top: 0; } } diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx index fa3dfe5..f5c16cd 100644 --- a/src/components/organisms/layout/summary.tsx +++ b/src/components/organisms/layout/summary.tsx @@ -2,6 +2,7 @@ import NextImage, { type ImageProps as NextImageProps } from 'next/image'; import type { FC, ReactNode } from 'react'; import { useIntl } from 'react-intl'; import type { Article, Meta as MetaType } from '../../../types'; +import { getFormattedDate } from '../../../utils/helpers'; import { useReadingTime } from '../../../utils/hooks'; import { ButtonLink, @@ -11,7 +12,7 @@ import { Link, Figure, } from '../../atoms'; -import { Meta, type MetaData } from '../../molecules'; +import { MetaList, type MetaItemData } from '../../molecules'; import styles from './summary.module.scss'; export type Cover = Pick<NextImageProps, 'alt' | 'src' | 'width' | 'height'>; @@ -69,42 +70,134 @@ export const Summary: FC<SummaryProps> = ({ ), } ); - const { author, commentsCount, cover, dates, thematics, topics, wordsCount } = - meta; - const readingTime = useReadingTime(wordsCount, true); + const readingTime = useReadingTime(meta.wordsCount, true); - const getMeta = (): MetaData => { - return { - author: author?.name, - publication: { date: dates.publication }, - update: - dates.update && dates.publication !== dates.update - ? { date: dates.update } - : undefined, - readingTime, - thematics: thematics?.map((thematic) => ( - <Link key={thematic.id} href={thematic.url}> - {thematic.name} - </Link> - )), - topics: topics?.map((topic) => ( - <Link key={topic.id} href={topic.url}> - {topic.name} - </Link> - )), - comments: { - about: title, - count: commentsCount ?? 0, - target: `${url}#comments`, + /** + * Retrieve a formatted date (and time). + * + * @param {string} date - A date string. + * @returns {JSX.Element} The formatted date wrapped in a time element. + */ + const getDate = (date: string): JSX.Element => { + const isoDate = new Date(`${date}`).toISOString(); + + return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; + }; + + const getMetaItems = (): MetaItemData[] => { + const summaryMeta: MetaItemData[] = [ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'Summary: publication date label', + id: 'TvQ2Ee', + }), + value: getDate(meta.dates.publication), }, - }; + ]; + + if (meta.dates.update && meta.dates.update !== meta.dates.publication) + summaryMeta.push({ + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'Summary: update date label', + id: 'f0Z/Po', + }), + value: getDate(meta.dates.update), + }); + + summaryMeta.push({ + id: 'reading-time', + label: intl.formatMessage({ + defaultMessage: 'Reading time:', + description: 'Summary: reading time label', + id: 'tyzdql', + }), + value: readingTime, + }); + + if (meta.author) + summaryMeta.push({ + id: 'author', + label: intl.formatMessage({ + defaultMessage: 'Written by:', + description: 'Summary: author label', + id: 'r/6HOI', + }), + value: meta.author.name, + }); + + if (meta.thematics) + summaryMeta.push({ + id: 'thematics', + label: intl.formatMessage({ + defaultMessage: 'Thematics:', + description: 'Summary: thematics label', + id: 'bk0WOp', + }), + value: meta.thematics.map((thematic) => { + return { + id: `thematic-${thematic.id}`, + value: <Link href={thematic.url}>{thematic.name}</Link>, + }; + }), + }); + + if (meta.topics) + summaryMeta.push({ + id: 'topics', + label: intl.formatMessage({ + defaultMessage: 'Topics:', + description: 'Summary: topics label', + id: 'yIZ+AC', + }), + value: meta.topics.map((topic) => { + return { + id: `topic-${topic.id}`, + value: <Link href={topic.url}>{topic.name}</Link>, + }; + }), + }); + + if (meta.commentsCount !== undefined) { + const commentsCount = intl.formatMessage( + { + defaultMessage: + '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>', + description: 'Summary: comments count', + id: 'ye/vlA', + }, + { + a11y: (chunks: ReactNode) => ( + <span className="screen-reader-text">{chunks}</span> + ), + commentsCount: meta.commentsCount, + title, + } + ); + summaryMeta.push({ + id: 'comments-count', + label: intl.formatMessage({ + defaultMessage: 'Comments:', + description: 'Summary: comments label', + id: 'bfPp0g', + }), + value: ( + <Link href={`${url}#comments`}>{commentsCount as JSX.Element}</Link> + ), + }); + } + + return summaryMeta; }; return ( <article className={styles.wrapper}> - {cover ? ( + {meta.cover ? ( <Figure> - <NextImage {...cover} className={styles.cover} /> + <NextImage {...meta.cover} className={styles.cover} /> </Figure> ) : null} <header className={styles.header}> @@ -121,21 +214,19 @@ export const Summary: FC<SummaryProps> = ({ dangerouslySetInnerHTML={{ __html: intro }} /> <ButtonLink className={styles['read-more']} to={url}> - <> - {readMore} - <Icon - aria-hidden={true} - className={styles.icon} - // eslint-disable-next-line react/jsx-no-literals -- Direction allowed - orientation="right" - // eslint-disable-next-line react/jsx-no-literals -- Shape allowed - shape="arrow" - /> - </> + {readMore} + <Icon + aria-hidden={true} + className={styles.icon} + // eslint-disable-next-line react/jsx-no-literals -- Direction allowed + orientation="right" + // eslint-disable-next-line react/jsx-no-literals -- Shape allowed + shape="arrow" + /> </ButtonLink> </div> <footer className={styles.footer}> - <Meta className={styles.meta} data={getMeta()} spacing="xs" /> + <MetaList className={styles.meta} items={getMetaItems()} /> </footer> </article> ); diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx index 683b6b2..7977382 100644 --- a/src/components/templates/page/page-layout.stories.tsx +++ b/src/components/templates/page/page-layout.stories.tsx @@ -271,23 +271,38 @@ Post.args = { breadcrumb: postBreadcrumb, title: pageTitle, intro: pageIntro, - headerMeta: { - publication: { date: '2020-03-14' }, - thematics: [ - <Link key="cat1" href="#"> - Cat 1 - </Link>, - <Link key="cat2" href="#"> - Cat 2 - </Link>, - ], - }, - footerMeta: { - custom: { + headerMeta: [ + { id: 'publication-date', label: 'Published on:', value: '2020-03-14' }, + { + id: 'thematics', + label: 'Thematics:', + value: [ + { + id: 'cat-1', + value: ( + <Link key="cat1" href="#"> + Cat 1 + </Link> + ), + }, + { + id: 'cat-2', + value: ( + <Link key="cat2" href="#"> + Cat 2 + </Link> + ), + }, + ], + }, + ], + footerMeta: [ + { + id: 'read-more', label: 'Read more about:', value: <ButtonLink to="#">Topic 1</ButtonLink>, }, - }, + ], children: ( <> <Heading level={2}>Impedit commodi rerum</Heading> @@ -357,7 +372,7 @@ export const Blog = Template.bind({}); Blog.args = { breadcrumb: postsListBreadcrumb, title: 'Blog', - headerMeta: { total: posts.length }, + headerMeta: [{ id: 'total', label: 'Total:', value: `${posts.length}` }], children: ( <PostsList posts={posts} diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx index ee3fd3a..dbac43e 100644 --- a/src/components/templates/page/page-layout.tsx +++ b/src/components/templates/page/page-layout.tsx @@ -16,7 +16,6 @@ import { Heading, Notice, type NoticeKind, Sidebar } from '../../atoms'; import { Breadcrumb, type BreadcrumbItem, - type MetaData, PageFooter, type PageFooterProps, PageHeader, @@ -41,13 +40,6 @@ const hasComments = ( ): comments is SingleComment[] => Array.isArray(comments) && comments.length > 0; -/** - * Check if meta properties are defined. - * - * @param {MetaData} meta - The metadata. - */ -const hasMeta = (meta: MetaData) => Object.values(meta).every((value) => value); - type CommentStatus = { isReply: boolean; kind: NoticeKind; @@ -256,7 +248,7 @@ export const PageLayout: FC<PageLayoutProps> = ({ {children} </div> )} - {footerMeta && hasMeta(footerMeta) ? ( + {footerMeta?.length ? ( <PageFooter meta={footerMeta} className={styles.footer} /> ) : null} <Sidebar diff --git a/src/i18n/en.json b/src/i18n/en.json index 9c33d2a..92a0c45 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -31,10 +31,6 @@ "defaultMessage": "Related thematics", "description": "TopicPage: related thematics list widget title" }, - "02rgLO": { - "defaultMessage": "{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>", - "description": "Meta: comments count" - }, "0gVlI3": { "defaultMessage": "Tracking:", "description": "AckeeToggle: select label" @@ -59,6 +55,10 @@ "defaultMessage": "Name:", "description": "ContactForm: name label" }, + "24FIsG": { + "defaultMessage": "Updated on:", + "description": "ThematicPage: update date label" + }, "28GZdv": { "defaultMessage": "Projects", "description": "Breadcrumb: projects label" @@ -99,6 +99,10 @@ "defaultMessage": "Page not found.", "description": "404Page: SEO - Meta description" }, + "4QbTDq": { + "defaultMessage": "Published on:", + "description": "Page: publication date label" + }, "4iYISO": { "defaultMessage": "Loading the requested article...", "description": "ArticlePage: loading article message" @@ -151,9 +155,9 @@ "defaultMessage": "{website} picture", "description": "Layout: photo alternative text" }, - "92zgdp": { - "defaultMessage": "Total:", - "description": "Meta: total label" + "9DfuHk": { + "defaultMessage": "Updated on:", + "description": "TopicPage: update date label" }, "9MeLN3": { "defaultMessage": "{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}", @@ -179,10 +183,6 @@ "defaultMessage": "Contact", "description": "ContactPage: page title" }, - "AuGklx": { - "defaultMessage": "License:", - "description": "Meta: license label" - }, "B290Ph": { "defaultMessage": "Thanks, your comment was successfully sent.", "description": "PageLayout: comment form success message" @@ -199,6 +199,10 @@ "defaultMessage": "Failed to load.", "description": "BlogPage: failed to load text" }, + "CvOqoh": { + "defaultMessage": "Thematics:", + "description": "ArticlePage: thematics meta label" + }, "D8vB38": { "defaultMessage": "Blog", "description": "Layout: main nav - blog link" @@ -211,14 +215,6 @@ "defaultMessage": "Thematics", "description": "SearchPage: thematics list widget title" }, - "DssFG1": { - "defaultMessage": "Repositories:", - "description": "Meta: repositories label" - }, - "EbFvsM": { - "defaultMessage": "Reading time:", - "description": "Meta: reading time label" - }, "EeCqAE": { "defaultMessage": "Loading the search results...", "description": "SearchPage: loading search results message" @@ -227,14 +223,14 @@ "defaultMessage": "Blog", "description": "Breadcrumb: blog label" }, + "Ez8Qim": { + "defaultMessage": "Updated on:", + "description": "Page: update date label" + }, "G+Twgm": { "defaultMessage": "Search", "description": "SearchModal: modal title" }, - "GRyyfy": { - "defaultMessage": "Official website:", - "description": "Meta: official website label" - }, "GTbGMy": { "defaultMessage": "Open menu", "description": "MainNav: Open label" @@ -243,6 +239,10 @@ "defaultMessage": "Topics", "description": "Error404Page: topics list widget title" }, + "Gw7X3x": { + "defaultMessage": "Reading time:", + "description": "ArticlePage: reading time label" + }, "HFdzae": { "defaultMessage": "Contact form", "description": "ContactForm: form accessible name" @@ -255,6 +255,10 @@ "defaultMessage": "Thematics", "description": "BlogPage: thematics list widget title" }, + "HxZvY4": { + "defaultMessage": "Published on:", + "description": "ProjectsPage: publication date label" + }, "IY5ew6": { "defaultMessage": "Submitting...", "description": "CommentForm: spinner message on submit" @@ -271,6 +275,10 @@ "defaultMessage": "Skip to content", "description": "Layout: Skip to content link" }, + "KV+NMZ": { + "defaultMessage": "Published on:", + "description": "TopicPage: publication date label" + }, "KVSWGP": { "defaultMessage": "Other thematics", "description": "ThematicPage: other thematics list widget title" @@ -279,6 +287,10 @@ "defaultMessage": "Page not found", "description": "Error404Page: page title" }, + "KrNvQi": { + "defaultMessage": "Popularity:", + "description": "ProjectsPage: popularity label" + }, "LCorTC": { "defaultMessage": "Cancel reply", "description": "Comment: cancel reply button" @@ -287,10 +299,18 @@ "defaultMessage": "Close search", "description": "Search: Close label" }, + "Ld6yMP": { + "defaultMessage": "{date} at {time}", + "description": "Comment: publication date and time" + }, "LszkU6": { "defaultMessage": "All posts in {thematicName}", "description": "ThematicPage: posts list heading" }, + "MJbZfX": { + "defaultMessage": "Written by:", + "description": "ArticlePage: author label" + }, "N44SOc": { "defaultMessage": "Projects", "description": "HomePage: link to projects" @@ -307,18 +327,10 @@ "defaultMessage": "Github profile", "description": "ProjectsPage: Github profile link" }, - "OF5cPz": { - "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}", - "description": "BlogPage: posts count meta" - }, "OHvb01": { "defaultMessage": "Back to top", "description": "SiteFooter: an accessible name for the back to top button" }, - "OI0N37": { - "defaultMessage": "Written by:", - "description": "Meta: author label" - }, "OL0Yzx": { "defaultMessage": "Publish", "description": "CommentForm: submit button" @@ -343,10 +355,6 @@ "defaultMessage": "Open settings", "description": "Settings: Open label" }, - "QGi5uD": { - "defaultMessage": "Published on:", - "description": "Meta: publication date label" - }, "QLisK6": { "defaultMessage": "Dark Theme 🌙", "description": "usePrism: toggle dark theme button text" @@ -363,10 +371,22 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, + "RecdwX": { + "defaultMessage": "Published on:", + "description": "ArticlePage: publication date label" + }, + "RvGb2c": { + "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}", + "description": "Page: posts count meta" + }, "RwI3B9": { "defaultMessage": "Loading the repository popularity...", "description": "ProjectsPage: loading repository popularity" }, + "RwNZ6p": { + "defaultMessage": "Technologies:", + "description": "ProjectsPage: technologies label" + }, "Sm2wCk": { "defaultMessage": "LinkedIn profile", "description": "CVPage: LinkedIn profile link" @@ -383,6 +403,14 @@ "defaultMessage": "An error occurred:", "description": "Contact: error message" }, + "TvQ2Ee": { + "defaultMessage": "Published on:", + "description": "Summary: publication date label" + }, + "UTGhUU": { + "defaultMessage": "Published on:", + "description": "ThematicPage: publication date label" + }, "UsQske": { "defaultMessage": "Read more here:", "description": "Sharing: content link prefix" @@ -395,6 +423,10 @@ "defaultMessage": "It is now awaiting moderation.", "description": "PageLayout: comment awaiting moderation" }, + "VtYzuv": { + "defaultMessage": "License:", + "description": "ProjectsPage: license label" + }, "WDwNDl": { "defaultMessage": "Search", "description": "SearchPage: SEO - Page title" @@ -427,6 +459,10 @@ "defaultMessage": "Light theme", "description": "ThemeToggle: light theme label" }, + "ZAqGZ6": { + "defaultMessage": "Updated on:", + "description": "ArticlePage: update date label" + }, "ZB/Aw2": { "defaultMessage": "Partial includes only page url, views and duration.", "description": "AckeeToggle: tooltip message" @@ -455,18 +491,18 @@ "defaultMessage": "You should read {title}", "description": "Sharing: subject text" }, - "b4fdYE": { - "defaultMessage": "Created on:", - "description": "Meta: creation date label" + "bfPp0g": { + "defaultMessage": "Comments:", + "description": "Summary: comments label" + }, + "bk0WOp": { + "defaultMessage": "Thematics:", + "description": "Summary: thematics label" }, "bojYF5": { "defaultMessage": "Home", "description": "Layout: main nav - home link" }, - "bz53Us": { - "defaultMessage": "Thematics:", - "description": "Meta: thematics label" - }, "c556Qo": { "defaultMessage": "Sidebar", "description": "PageLayout: accessible name for the sidebar" @@ -475,6 +511,10 @@ "defaultMessage": "Comment form", "description": "CommentForm: aria label" }, + "f0Z/Po": { + "defaultMessage": "Updated on:", + "description": "Summary: update date label" + }, "fN04AJ": { "defaultMessage": "<link>Download the CV in PDF</link>", "description": "CVPage: download CV in PDF text" @@ -483,10 +523,6 @@ "defaultMessage": "Failed to load.", "description": "SearchPage: failed to load text" }, - "fcHeyC": { - "defaultMessage": "{date} at {time}", - "description": "Meta: publication date and time" - }, "fkcTGp": { "defaultMessage": "An error occurred:", "description": "PageLayout: comment form error message" @@ -499,10 +535,6 @@ "defaultMessage": "It has been approved.", "description": "PageLayout: comment approved." }, - "gJNaBD": { - "defaultMessage": "Topics:", - "description": "Meta: topics label" - }, "gPfT/K": { "defaultMessage": "Settings", "description": "SettingsModal: title" @@ -523,6 +555,14 @@ "defaultMessage": "{count} seconds", "description": "useReadingTime: seconds count" }, + "iDIKb7": { + "defaultMessage": "Repositories:", + "description": "ProjectsPage: repositories label" + }, + "iv3Ex1": { + "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}", + "description": "ThematicPage: posts count meta" + }, "j5k9Fe": { "defaultMessage": "Home", "description": "Breadcrumb: home label" @@ -531,14 +571,18 @@ "defaultMessage": "Linux", "description": "HomePage: link to Linux thematic" }, - "jTVIh8": { - "defaultMessage": "Comments:", - "description": "Meta: comments label" + "kNBXyK": { + "defaultMessage": "Total:", + "description": "Page: total label" }, "kzIYoQ": { "defaultMessage": "Leave a comment", "description": "PageLayout: comment form title" }, + "lHkta9": { + "defaultMessage": "Total:", + "description": "ThematicPage: total label" + }, "lKhTGM": { "defaultMessage": "Use Ctrl+c to copy", "description": "usePrism: copy button error text" @@ -575,14 +619,14 @@ "defaultMessage": "Footer", "description": "SiteFooter: an accessible name for the footer nav" }, + "pT5nHk": { + "defaultMessage": "Published on:", + "description": "HomePage: publication date label" + }, "pWKyyR": { "defaultMessage": "Off", "description": "MotionToggle: deactivate reduce motion label" }, - "pWTj2W": { - "defaultMessage": "Popularity:", - "description": "Meta: popularity label" - }, "pg26sn": { "defaultMessage": "Discover search results for {query} on {websiteName}.", "description": "SearchPage: SEO - Meta description" @@ -595,6 +639,10 @@ "defaultMessage": "Projects", "description": "Layout: main nav - projects link" }, + "r/6HOI": { + "defaultMessage": "Written by:", + "description": "Summary: author label" + }, "s1i43J": { "defaultMessage": "{minutesCount} minutes", "description": "useReadingTime: rounded minutes count" @@ -619,18 +667,22 @@ "defaultMessage": "Contact me", "description": "HomePage: contact button text" }, + "soj7do": { + "defaultMessage": "Published on:", + "description": "Comment: publication date label" + }, "suXOBu": { "defaultMessage": "Theme:", "description": "ThemeToggle: theme label" }, + "tBX4mb": { + "defaultMessage": "Total:", + "description": "TopicPage: total label" + }, "tIZYpD": { "defaultMessage": "Partial", "description": "AckeeToggle: partial option name" }, - "tLC7bh": { - "defaultMessage": "Updated on:", - "description": "Meta: update date label" - }, "tMuNTy": { "defaultMessage": "{websiteName} is a front-end developer located in France. He codes and he writes mostly about web development and open-source.", "description": "HomePage: SEO - Meta description" @@ -639,10 +691,18 @@ "defaultMessage": "Light theme", "description": "PrismThemeToggle: light theme label" }, + "tyzdql": { + "defaultMessage": "Reading time:", + "description": "Summary: reading time label" + }, "u41qSk": { "defaultMessage": "Website:", "description": "CommentForm: website label" }, + "uAL4iW": { + "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}", + "description": "TopicPage: posts count meta" + }, "uaqd5F": { "defaultMessage": "Load more articles?", "description": "PostsList: load more button" @@ -667,6 +727,14 @@ "defaultMessage": "Free", "description": "HomePage: link to free thematic" }, + "wQrvgw": { + "defaultMessage": "Updated on:", + "description": "ProjectsPage: update date label" + }, + "wVFA4m": { + "defaultMessage": "Created on:", + "description": "ProjectsPage: creation date label" + }, "xYNeKX": { "defaultMessage": "Settings form", "description": "SettingsModal: an accessible form name" @@ -687,10 +755,18 @@ "defaultMessage": "You are here:", "description": "Pagination: current page indication" }, + "yIZ+AC": { + "defaultMessage": "Topics:", + "description": "Summary: topics label" + }, "yN5P+m": { "defaultMessage": "Message:", "description": "ContactForm: message label" }, + "ye/vlA": { + "defaultMessage": "{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>", + "description": "Summary: comments count" + }, "yfgMcl": { "defaultMessage": "Introduction:", "description": "Sharing: email content prefix" @@ -702,5 +778,9 @@ "zbzlb1": { "defaultMessage": "Page {number}", "description": "BlogPage: page number" + }, + "zoifQd": { + "defaultMessage": "Official website:", + "description": "TopicPage: official website label" } } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 997e0e0..f602b20 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -31,10 +31,6 @@ "defaultMessage": "Thématiques liées", "description": "TopicPage: related thematics list widget title" }, - "02rgLO": { - "defaultMessage": "{commentsCount, plural, =0 {0 commentaire} one {# commentaire} other {# commentaires}}<a11y> à propos de {title}</a11y>", - "description": "Meta: comments count" - }, "0gVlI3": { "defaultMessage": "Suivi :", "description": "AckeeToggle: select label" @@ -59,6 +55,10 @@ "defaultMessage": "Nom :", "description": "ContactForm: name label" }, + "24FIsG": { + "defaultMessage": "Mis à jour le :", + "description": "ThematicPage: update date label" + }, "28GZdv": { "defaultMessage": "Projets", "description": "Breadcrumb: projects label" @@ -99,6 +99,10 @@ "defaultMessage": "Page non trouvée.", "description": "404Page: SEO - Meta description" }, + "4QbTDq": { + "defaultMessage": "Publié le :", + "description": "Page: publication date label" + }, "4iYISO": { "defaultMessage": "Chargement de l’article demandé…", "description": "ArticlePage: loading article message" @@ -151,9 +155,9 @@ "defaultMessage": "Photo d’{website}", "description": "Layout: photo alternative text" }, - "92zgdp": { - "defaultMessage": "Total :", - "description": "Meta: total label" + "9DfuHk": { + "defaultMessage": "Mis à jour le :", + "description": "TopicPage: update date label" }, "9MeLN3": { "defaultMessage": "{articlesCount, plural, =0 {# article chargé} one {# article chargé} other {# articles chargés}} sur un total de {total}", @@ -179,10 +183,6 @@ "defaultMessage": "Contact", "description": "ContactPage: page title" }, - "AuGklx": { - "defaultMessage": "Licence :", - "description": "Meta: license label" - }, "B290Ph": { "defaultMessage": "Merci, votre commentaire a été envoyé avec succès.", "description": "PageLayout: comment form success message" @@ -199,6 +199,10 @@ "defaultMessage": "Échec du chargement.", "description": "BlogPage: failed to load text" }, + "CvOqoh": { + "defaultMessage": "Thématiques :", + "description": "ArticlePage: thematics meta label" + }, "D8vB38": { "defaultMessage": "Blog", "description": "Layout: main nav - blog link" @@ -211,14 +215,6 @@ "defaultMessage": "Thématiques", "description": "SearchPage: thematics list widget title" }, - "DssFG1": { - "defaultMessage": "Dépôts :", - "description": "Meta: repositories label" - }, - "EbFvsM": { - "defaultMessage": "Temps de lecture :", - "description": "Meta: reading time label" - }, "EeCqAE": { "defaultMessage": "Chargement des résultats…", "description": "SearchPage: loading search results message" @@ -227,14 +223,14 @@ "defaultMessage": "Blog", "description": "Breadcrumb: blog label" }, + "Ez8Qim": { + "defaultMessage": "Mis à jour le :", + "description": "Page: update date label" + }, "G+Twgm": { "defaultMessage": "Recherche", "description": "SearchModal: modal title" }, - "GRyyfy": { - "defaultMessage": "Site officiel :", - "description": "Meta: official website label" - }, "GTbGMy": { "defaultMessage": "Ouvrir le menu", "description": "MainNav: Open label" @@ -243,6 +239,10 @@ "defaultMessage": "Sujets", "description": "Error404Page: topics list widget title" }, + "Gw7X3x": { + "defaultMessage": "Temps de lecture :", + "description": "ArticlePage: reading time label" + }, "HFdzae": { "defaultMessage": "Formulaire de contact", "description": "ContactForm: form accessible name" @@ -255,6 +255,10 @@ "defaultMessage": "Thématiques", "description": "BlogPage: thematics list widget title" }, + "HxZvY4": { + "defaultMessage": "Publié le :", + "description": "ProjectsPage: publication date label" + }, "IY5ew6": { "defaultMessage": "En cours d’envoi…", "description": "CommentForm: spinner message on submit" @@ -271,6 +275,10 @@ "defaultMessage": "Aller au contenu", "description": "Layout: Skip to content link" }, + "KV+NMZ": { + "defaultMessage": "Publié le :", + "description": "TopicPage: publication date label" + }, "KVSWGP": { "defaultMessage": "Autres thématiques", "description": "ThematicPage: other thematics list widget title" @@ -279,6 +287,10 @@ "defaultMessage": "Page non trouvée", "description": "Error404Page: page title" }, + "KrNvQi": { + "defaultMessage": "Popularité :", + "description": "ProjectsPage: popularity label" + }, "LCorTC": { "defaultMessage": "Annuler la réponse", "description": "Comment: cancel reply button" @@ -287,10 +299,18 @@ "defaultMessage": "Fermer la recherche", "description": "Search: Close label" }, + "Ld6yMP": { + "defaultMessage": "{date} à {time}", + "description": "Comment: publication date and time" + }, "LszkU6": { "defaultMessage": "Tous les articles dans {thematicName}", "description": "ThematicPage: posts list heading" }, + "MJbZfX": { + "defaultMessage": "Écrit par :", + "description": "ArticlePage: author label" + }, "N44SOc": { "defaultMessage": "Projets", "description": "HomePage: link to projects" @@ -307,18 +327,10 @@ "defaultMessage": "Profil Github", "description": "ProjectsPage: Github profile link" }, - "OF5cPz": { - "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}", - "description": "BlogPage: posts count meta" - }, "OHvb01": { "defaultMessage": "Retour en haut de page", "description": "SiteFooter: an accessible name for the back to top button" }, - "OI0N37": { - "defaultMessage": "Écrit par :", - "description": "Meta: author label" - }, "OL0Yzx": { "defaultMessage": "Publier", "description": "CommentForm: submit button" @@ -343,10 +355,6 @@ "defaultMessage": "Ouvrir les réglages", "description": "Settings: Open label" }, - "QGi5uD": { - "defaultMessage": "Publié le :", - "description": "Meta: publication date label" - }, "QLisK6": { "defaultMessage": "Thème sombre 🌙", "description": "usePrism: toggle dark theme button text" @@ -363,10 +371,22 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, + "RecdwX": { + "defaultMessage": "Publié le :", + "description": "ArticlePage: publication date label" + }, + "RvGb2c": { + "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}", + "description": "Page: posts count meta" + }, "RwI3B9": { "defaultMessage": "Chargement de la popularité du dépôt…", "description": "ProjectsPage: loading repository popularity" }, + "RwNZ6p": { + "defaultMessage": "Technologies :", + "description": "ProjectsPage: technologies label" + }, "Sm2wCk": { "defaultMessage": "Profil LinkedIn", "description": "CVPage: LinkedIn profile link" @@ -383,6 +403,14 @@ "defaultMessage": "Une erreur est survenue :", "description": "Contact: error message" }, + "TvQ2Ee": { + "defaultMessage": "Publié le :", + "description": "Summary: publication date label" + }, + "UTGhUU": { + "defaultMessage": "Publié le :", + "description": "ThematicPage: publication date label" + }, "UsQske": { "defaultMessage": "En lire plus ici :", "description": "Sharing: content link prefix" @@ -395,6 +423,10 @@ "defaultMessage": "Il est maintenant en attente de modération.", "description": "PageLayout: comment awaiting moderation" }, + "VtYzuv": { + "defaultMessage": "License :", + "description": "ProjectsPage: license label" + }, "WDwNDl": { "defaultMessage": "Recherche", "description": "SearchPage: SEO - Page title" @@ -427,6 +459,10 @@ "defaultMessage": "Thème clair", "description": "ThemeToggle: light theme label" }, + "ZAqGZ6": { + "defaultMessage": "Mis à jour le :", + "description": "ArticlePage: update date label" + }, "ZB/Aw2": { "defaultMessage": "Partiel inclut seulement l’url de la page, le nombre de visites et la durée.", "description": "AckeeToggle: tooltip message" @@ -455,18 +491,18 @@ "defaultMessage": "Vous devriez lire {title}", "description": "Sharing: subject text" }, - "b4fdYE": { - "defaultMessage": "Créé le :", - "description": "Meta: creation date label" + "bfPp0g": { + "defaultMessage": "Commentaires :", + "description": "Summary: comments label" + }, + "bk0WOp": { + "defaultMessage": "Thématiques :", + "description": "Summary: thematics label" }, "bojYF5": { "defaultMessage": "Accueil", "description": "Layout: main nav - home link" }, - "bz53Us": { - "defaultMessage": "Thématiques :", - "description": "Meta: thematics label" - }, "c556Qo": { "defaultMessage": "Barre latérale", "description": "PageLayout: accessible name for the sidebar" @@ -475,6 +511,10 @@ "defaultMessage": "Formulaire des commentaires", "description": "CommentForm: aria label" }, + "f0Z/Po": { + "defaultMessage": "Mis à jour le :", + "description": "Summary: update date label" + }, "fN04AJ": { "defaultMessage": "<link>Télécharger le CV au format PDF</link>", "description": "CVPage: download CV in PDF text" @@ -483,10 +523,6 @@ "defaultMessage": "Échec du chargement.", "description": "SearchPage: failed to load text" }, - "fcHeyC": { - "defaultMessage": "{date} à {time}", - "description": "Meta: publication date and time" - }, "fkcTGp": { "defaultMessage": "Une erreur est survenue :", "description": "PageLayout: comment form error message" @@ -499,10 +535,6 @@ "defaultMessage": "Il a été approuvé.", "description": "PageLayout: comment approved." }, - "gJNaBD": { - "defaultMessage": "Sujets :", - "description": "Meta: topics label" - }, "gPfT/K": { "defaultMessage": "Réglages", "description": "SettingsModal: title" @@ -523,6 +555,14 @@ "defaultMessage": "{count} secondes", "description": "useReadingTime: seconds count" }, + "iDIKb7": { + "defaultMessage": "Dépôts :", + "description": "ProjectsPage: repositories label" + }, + "iv3Ex1": { + "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}", + "description": "ThematicPage: posts count meta" + }, "j5k9Fe": { "defaultMessage": "Accueil", "description": "Breadcrumb: home label" @@ -531,14 +571,18 @@ "defaultMessage": "Linux", "description": "HomePage: link to Linux thematic" }, - "jTVIh8": { - "defaultMessage": "Commentaires :", - "description": "Meta: comments label" + "kNBXyK": { + "defaultMessage": "Total :", + "description": "Page: total label" }, "kzIYoQ": { "defaultMessage": "Laisser un commentaire", "description": "PageLayout: comment form title" }, + "lHkta9": { + "defaultMessage": "Total :", + "description": "ThematicPage: total label" + }, "lKhTGM": { "defaultMessage": "Utilisez Ctrl+c pour copier", "description": "usePrism: copy button error text" @@ -575,14 +619,14 @@ "defaultMessage": "Pied de page", "description": "SiteFooter: an accessible name for the footer nav" }, + "pT5nHk": { + "defaultMessage": "Publié le :", + "description": "HomePage: publication date label" + }, "pWKyyR": { "defaultMessage": "Arrêt", "description": "MotionToggle: deactivate reduce motion label" }, - "pWTj2W": { - "defaultMessage": "Popularité :", - "description": "Meta: popularity label" - }, "pg26sn": { "defaultMessage": "Découvrez les résultats de recherche pour {query} sur {websiteName}.", "description": "SearchPage: SEO - Meta description" @@ -595,6 +639,10 @@ "defaultMessage": "Projets", "description": "Layout: main nav - projects link" }, + "r/6HOI": { + "defaultMessage": "Écrit par :", + "description": "Summary: author label" + }, "s1i43J": { "defaultMessage": "{minutesCount} minutes", "description": "useReadingTime: rounded minutes count" @@ -619,18 +667,22 @@ "defaultMessage": "Me contacter", "description": "HomePage: contact button text" }, + "soj7do": { + "defaultMessage": "Publié le :", + "description": "Comment: publication date label" + }, "suXOBu": { "defaultMessage": "Thème :", "description": "ThemeToggle: theme label" }, + "tBX4mb": { + "defaultMessage": "Total :", + "description": "TopicPage: total label" + }, "tIZYpD": { "defaultMessage": "Partiel", "description": "AckeeToggle: partial option name" }, - "tLC7bh": { - "defaultMessage": "Mis à jour le :", - "description": "Meta: update date label" - }, "tMuNTy": { "defaultMessage": "{websiteName} est intégrateur web / développeur front-end en France. Il code et il écrit essentiellement à propos de développement web et du libre.", "description": "HomePage: SEO - Meta description" @@ -639,10 +691,18 @@ "defaultMessage": "Thème clair", "description": "PrismThemeToggle: light theme label" }, + "tyzdql": { + "defaultMessage": "Temps de lecture :", + "description": "Summary: reading time label" + }, "u41qSk": { "defaultMessage": "Site web :", "description": "CommentForm: website label" }, + "uAL4iW": { + "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}", + "description": "TopicPage: posts count meta" + }, "uaqd5F": { "defaultMessage": "Charger plus d’articles ?", "description": "PostsList: load more button" @@ -667,6 +727,14 @@ "defaultMessage": "Libre", "description": "HomePage: link to free thematic" }, + "wQrvgw": { + "defaultMessage": "Mis à jour le :", + "description": "ProjectsPage: update date label" + }, + "wVFA4m": { + "defaultMessage": "Créé le :", + "description": "ProjectsPage: creation date label" + }, "xYNeKX": { "defaultMessage": "Formulaire des réglages", "description": "SettingsModal: an accessible form name" @@ -687,10 +755,18 @@ "defaultMessage": "Vous êtes ici :", "description": "Pagination: current page indication" }, + "yIZ+AC": { + "defaultMessage": "Sujets :", + "description": "Summary: topics label" + }, "yN5P+m": { "defaultMessage": "Message :", "description": "ContactForm: message label" }, + "ye/vlA": { + "defaultMessage": "{commentsCount, plural, =0 {0 commentaire} one {# commentaire} other {# commentaires}}<a11y> à propos de {title}</a11y>", + "description": "Summary: comments count" + }, "yfgMcl": { "defaultMessage": "Introduction :", "description": "Sharing: email content prefix" @@ -702,5 +778,9 @@ "zbzlb1": { "defaultMessage": "Page {number}", "description": "BlogPage: page number" + }, + "zoifQd": { + "defaultMessage": "Site officiel :", + "description": "TopicPage: official website label" } } diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index acb80b2..bce493b 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -12,9 +12,9 @@ import { getLayout, Link, PageLayout, - type PageLayoutProps, Sharing, Spinner, + type MetaItemData, } from '../../components'; import { getAllArticlesSlugs, @@ -26,6 +26,7 @@ import type { Article, NextPageWithLayout, SingleComment } from '../../types'; import { ROUTES } from '../../utils/constants'; import { getBlogSchema, + getFormattedDate, getSchemaJson, getSinglePageSchema, getWebPageSchema, @@ -82,37 +83,113 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ const { content, id, intro, meta, title } = article; const { author, commentsCount, cover, dates, seo, thematics, topics } = meta; - const headerMeta: PageLayoutProps['headerMeta'] = { - author: author?.name, - publication: { date: dates.publication }, - update: - dates.update && dates.publication !== dates.update - ? { date: dates.update } - : undefined, - readingTime, - thematics: thematics?.map((thematic) => ( - <Link key={thematic.id} href={thematic.url}> - {thematic.name} - </Link> - )), + /** + * Retrieve a formatted date (and time). + * + * @param {string} date - A date string. + * @returns {JSX.Element} The formatted date wrapped in a time element. + */ + const getDate = (date: string): JSX.Element => { + const isoDate = new Date(`${date}`).toISOString(); + + return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; }; + const headerMeta: (MetaItemData | undefined)[] = [ + author + ? { + id: 'author', + label: intl.formatMessage({ + defaultMessage: 'Written by:', + description: 'ArticlePage: author label', + id: 'MJbZfX', + }), + value: author.name, + } + : undefined, + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'ArticlePage: publication date label', + id: 'RecdwX', + }), + value: getDate(dates.publication), + }, + dates.update && dates.publication !== dates.update + ? { + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'ArticlePage: update date label', + id: 'ZAqGZ6', + }), + value: getDate(dates.update), + } + : undefined, + { + id: 'reading-time', + label: intl.formatMessage({ + defaultMessage: 'Reading time:', + description: 'ArticlePage: reading time label', + id: 'Gw7X3x', + }), + value: readingTime, + }, + thematics + ? { + id: 'thematics', + label: intl.formatMessage({ + defaultMessage: 'Thematics:', + description: 'ArticlePage: thematics meta label', + id: 'CvOqoh', + }), + value: thematics.map((thematic) => { + return { + id: `thematic-${thematic.id}`, + value: ( + <Link key={thematic.id} href={thematic.url}> + {thematic.name} + </Link> + ), + }; + }), + } + : undefined, + ]; + const filteredHeaderMeta = headerMeta.filter( + (item): item is MetaItemData => !!item + ); + const footerMetaLabel = intl.formatMessage({ defaultMessage: 'Read more articles about:', description: 'ArticlePage: footer topics list label', id: '50xc4o', }); - const footerMeta: PageLayoutProps['footerMeta'] = { - custom: topics && { - label: footerMetaLabel, - value: topics.map((topic) => ( - <ButtonLink className={styles.btn} key={topic.id} to={topic.url}> - {topic.logo ? <NextImage {...topic.logo} /> : null} {topic.name} - </ButtonLink> - )), - }, - }; + const footerMeta: MetaItemData[] = topics + ? [ + { + id: 'more-about', + label: footerMetaLabel, + value: topics.map((topic) => { + return { + id: `topic--${topic.id}`, + value: ( + <ButtonLink + className={styles.btn} + key={topic.id} + to={topic.url} + > + {topic.logo ? <NextImage {...topic.logo} /> : null}{' '} + {topic.name} + </ButtonLink> + ), + }; + }), + }, + ] + : []; const webpageSchema = getWebPageSchema({ description: intro, @@ -208,7 +285,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ breadcrumbSchema={breadcrumbSchema} comments={commentsData} footerMeta={footerMeta} - headerMeta={headerMeta} + headerMeta={filteredHeaderMeta} id={id as number} intro={intro} title={title} diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 0241a5d..5c64e6d 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -9,6 +9,7 @@ import { getLayout, Heading, LinksListWidget, + type MetaItemData, Notice, PageLayout, PostsList, @@ -134,6 +135,28 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ }); const postsListBaseUrl = `${ROUTES.BLOG}/page/`; + const headerMeta: MetaItemData[] = totalArticles + ? [ + { + id: 'posts-count', + label: intl.formatMessage({ + defaultMessage: 'Total:', + description: 'Page: total label', + id: 'kNBXyK', + }), + value: intl.formatMessage( + { + defaultMessage: + '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', + description: 'Page: posts count meta', + id: 'RvGb2c', + }, + { postsCount: totalArticles } + ), + }, + ] + : []; + return ( <> <Head> @@ -157,7 +180,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ title={title} breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} - headerMeta={{ total: totalArticles }} + headerMeta={headerMeta} widgets={[ <LinksListWidget heading={ diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx index 15d7245..58cf7b9 100644 --- a/src/pages/blog/page/[number].tsx +++ b/src/pages/blog/page/[number].tsx @@ -9,6 +9,7 @@ import { getLayout, Heading, LinksListWidget, + type MetaItemData, PageLayout, PostsList, } from '../../../components'; @@ -132,6 +133,28 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ }); const postsListBaseUrl = `${ROUTES.BLOG}/page/`; + const headerMeta: MetaItemData[] = totalArticles + ? [ + { + id: 'posts-count', + label: intl.formatMessage({ + defaultMessage: 'Total:', + description: 'Page: total label', + id: 'kNBXyK', + }), + value: intl.formatMessage( + { + defaultMessage: + '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', + description: 'Page: posts count meta', + id: 'RvGb2c', + }, + { postsCount: totalArticles } + ), + }, + ] + : []; + return ( <> <Head> @@ -155,7 +178,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ title={pageTitleWithPageNumber} breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} - headerMeta={{ total: totalArticles }} + headerMeta={headerMeta} widgets={[ <LinksListWidget heading={ diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx index 206c7f5..652b913 100644 --- a/src/pages/cv.tsx +++ b/src/pages/cv.tsx @@ -18,14 +18,15 @@ import { List, PageLayout, SocialMedia, - type MetaData, ListItem, + type MetaItemData, } from '../components'; import CVContent, { data, meta } from '../content/pages/cv.mdx'; import styles from '../styles/pages/cv.module.scss'; import type { NextPageWithLayout } from '../types'; import { PERSONAL_LINKS, ROUTES } from '../utils/constants'; import { + getFormattedDate, getSchemaJson, getSinglePageSchema, getWebPageSchema, @@ -152,16 +153,43 @@ const CVPage: NextPageWithLayout = () => { id: '+Dre5J', }); - const headerMeta: MetaData = { - publication: { - date: dates.publication, + /** + * Retrieve a formatted date (and time). + * + * @param {string} date - A date string. + * @returns {JSX.Element} The formatted date wrapped in a time element. + */ + const getDate = (date: string): JSX.Element => { + const isoDate = new Date(`${date}`).toISOString(); + + return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; + }; + + const headerMeta: (MetaItemData | undefined)[] = [ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'Page: publication date label', + id: '4QbTDq', + }), + value: getDate(dates.publication), }, - update: dates.update + dates.update ? { - date: dates.update, + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'Page: update date label', + id: 'Ez8Qim', + }), + value: getDate(dates.update), } : undefined, - }; + ]; + const filteredMeta = headerMeta.filter( + (item): item is MetaItemData => !!item + ); const { website } = useSettings(); const cvCaption = intl.formatMessage( @@ -267,7 +295,7 @@ const CVPage: NextPageWithLayout = () => { <PageLayout breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} - headerMeta={headerMeta} + headerMeta={filteredMeta} intro={intro} title={title} widgets={widgets} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d94160f..cdc51c5 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-statements */ import type { MDXComponents } from 'mdx/types'; import type { GetStaticProps } from 'next'; import Head from 'next/head'; @@ -26,7 +27,11 @@ import { getArticlesCard } from '../services/graphql'; import styles from '../styles/pages/home.module.scss'; import type { ArticleCard, NextPageWithLayout } from '../types'; import { PERSONAL_LINKS, ROUTES } from '../utils/constants'; -import { getSchemaJson, getWebPageSchema } from '../utils/helpers'; +import { + getFormattedDate, + getSchemaJson, + getWebPageSchema, +} from '../utils/helpers'; import { loadTranslation, type Messages } from '../utils/helpers/server'; import { useBreadcrumb, useSettings } from '../utils/hooks'; @@ -279,6 +284,11 @@ type HomeProps = { */ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { const intl = useIntl(); + const publicationDate = intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'HomePage: publication date label', + id: 'pT5nHk', + }); const { schema: breadcrumbSchema } = useBreadcrumb({ title: '', url: `/`, @@ -291,10 +301,22 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { */ const getRecentPosts = (): JSX.Element => { const posts: CardsListItem[] = recentPosts.map((post) => { + const isoDate = new Date(`${post.dates.publication}`).toISOString(); + return { cover: post.cover, id: post.slug, - meta: { publication: { date: post.dates.publication } }, + meta: [ + { + id: 'publication-date', + label: publicationDate, + value: ( + <time dateTime={isoDate}> + {getFormattedDate(post.dates.publication)} + </time> + ), + }, + ], title: post.title, url: `${ROUTES.ARTICLE}/${post.slug}`, }; diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx index 810d9ec..25c2dd9 100644 --- a/src/pages/mentions-legales.tsx +++ b/src/pages/mentions-legales.tsx @@ -1,20 +1,23 @@ +/* eslint-disable max-statements */ import type { MDXComponents } from 'mdx/types'; import type { GetStaticProps } from 'next'; import Head from 'next/head'; import NextImage, { type ImageProps as NextImageProps } from 'next/image'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import { useIntl } from 'react-intl'; import { getLayout, Link, PageLayout, - type MetaData, Figure, + type MetaItemData, } from '../components'; import LegalNoticeContent, { meta } from '../content/pages/legal-notice.mdx'; import type { NextPageWithLayout } from '../types'; import { ROUTES } from '../utils/constants'; import { + getFormattedDate, getSchemaJson, getSinglePageSchema, getWebPageSchema, @@ -37,22 +40,50 @@ const components: MDXComponents = { * Legal Notice page. */ const LegalNoticePage: NextPageWithLayout = () => { + const intl = useIntl(); const { dates, intro, seo, title } = meta; const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ title, url: ROUTES.LEGAL_NOTICE, }); - const headerMeta: MetaData = { - publication: { - date: dates.publication, + /** + * Retrieve a formatted date (and time). + * + * @param {string} date - A date string. + * @returns {JSX.Element} The formatted date wrapped in a time element. + */ + const getDate = (date: string): JSX.Element => { + const isoDate = new Date(`${date}`).toISOString(); + + return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; + }; + + const headerMeta: (MetaItemData | undefined)[] = [ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'Page: publication date label', + id: '4QbTDq', + }), + value: getDate(dates.publication), }, - update: dates.update + dates.update ? { - date: dates.update, + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'Page: update date label', + id: 'Ez8Qim', + }), + value: getDate(dates.update), } : undefined, - }; + ]; + const filteredMeta = headerMeta.filter( + (item): item is MetaItemData => !!item + ); const { website } = useSettings(); const { asPath } = useRouter(); @@ -82,7 +113,7 @@ const LegalNoticePage: NextPageWithLayout = () => { <PageLayout breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} - headerMeta={headerMeta} + headerMeta={filteredMeta} intro={intro} title={title} withToC={true} diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx index 0b94a4e..6ef3df5 100644 --- a/src/pages/projets/[slug].tsx +++ b/src/pages/projets/[slug].tsx @@ -14,21 +14,22 @@ import { getLayout, Link, Overview, - type OverviewMeta, PageLayout, Sharing, SocialLink, Spinner, - type MetaData, Heading, List, ListItem, Figure, + type MetaItemData, + type MetaValues, } from '../../components'; import styles from '../../styles/pages/project.module.scss'; import type { NextPageWithLayout, ProjectPreview, Repos } from '../../types'; import { ROUTES } from '../../utils/constants'; import { + getFormattedDate, getSchemaJson, getSinglePageSchema, getWebPageSchema, @@ -166,22 +167,52 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { url: `${website.url}${asPath}`, }; - const headerMeta: MetaData = { - publication: { date: dates.publication }, - update: - dates.update && dates.update !== dates.publication - ? { date: dates.update } - : undefined, + /** + * Retrieve a formatted date (and time). + * + * @param {string} date - A date string. + * @returns {JSX.Element} The formatted date wrapped in a time element. + */ + const getDate = (date: string): JSX.Element => { + const isoDate = new Date(`${date}`).toISOString(); + + return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; }; + const headerMeta: (MetaItemData | undefined)[] = [ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'ProjectsPage: publication date label', + id: 'HxZvY4', + }), + value: getDate(dates.publication), + }, + dates.update && dates.update !== dates.publication + ? { + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'ProjectsPage: update date label', + id: 'wQrvgw', + }), + value: getDate(dates.update), + } + : undefined, + ]; + const filteredHeaderMeta = headerMeta.filter( + (item): item is MetaItemData => !!item + ); + /** * Retrieve the repositories links. * * @param {Repos} repositories - A repositories object. - * @returns {JSX.Element[]} - An array of SocialLink. + * @returns {MetaValues[]} - An array of meta values. */ - const getReposLinks = (repositories: Repos): JSX.Element[] => { - const links = []; + const getReposLinks = (repositories: Repos): MetaValues[] => { + const links: MetaValues[] = []; const githubLabel = intl.formatMessage({ defaultMessage: 'Github profile', description: 'ProjectsPage: Github profile link', @@ -194,22 +225,28 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { }); if (repositories.github) - links.push( - <SocialLink - icon="Github" - label={githubLabel} - url={repositories.github} - /> - ); + links.push({ + id: 'github', + value: ( + <SocialLink + icon="Github" + label={githubLabel} + url={repositories.github} + /> + ), + }); if (repositories.gitlab) - links.push( - <SocialLink - icon="Gitlab" - label={gitlabLabel} - url={repositories.gitlab} - /> - ); + links.push({ + id: 'gitlab', + value: ( + <SocialLink + icon="Gitlab" + label={gitlabLabel} + url={repositories.gitlab} + /> + ), + }); return links; }; @@ -254,14 +291,75 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { ); }; - const overviewData: OverviewMeta = { - creation: { date: data.created_at }, - update: { date: data.updated_at }, - license, - popularity: repos?.github && getRepoPopularity(repos.github), - repositories: repos ? getReposLinks(repos) : undefined, - technologies, - }; + const overviewMeta: (MetaItemData | undefined)[] = [ + { + id: 'creation-date', + label: intl.formatMessage({ + defaultMessage: 'Created on:', + description: 'ProjectsPage: creation date label', + id: 'wVFA4m', + }), + value: getDate(data.created_at), + }, + { + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'ProjectsPage: update date label', + id: 'wQrvgw', + }), + value: getDate(data.updated_at), + }, + license + ? { + id: 'license', + label: intl.formatMessage({ + defaultMessage: 'License:', + description: 'ProjectsPage: license label', + id: 'VtYzuv', + }), + value: license, + } + : undefined, + repos?.github + ? { + id: 'popularity', + label: intl.formatMessage({ + defaultMessage: 'Popularity:', + description: 'ProjectsPage: popularity label', + id: 'KrNvQi', + }), + value: getRepoPopularity(repos.github), + } + : undefined, + repos + ? { + id: 'repositories', + label: intl.formatMessage({ + defaultMessage: 'Repositories:', + description: 'ProjectsPage: repositories label', + id: 'iDIKb7', + }), + value: getReposLinks(repos), + } + : undefined, + technologies + ? { + id: 'technologies', + label: intl.formatMessage({ + defaultMessage: 'Technologies:', + description: 'ProjectsPage: technologies label', + id: 'RwNZ6p', + }), + value: technologies.map((techno) => { + return { id: techno, value: techno }; + }), + } + : undefined, + ]; + const filteredOverviewMeta = overviewMeta.filter( + (item): item is MetaItemData => !!item + ); const webpageSchema = getWebPageSchema({ description: seo.description, @@ -306,7 +404,7 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { intro={intro} breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} - headerMeta={headerMeta} + headerMeta={filteredHeaderMeta} withToC={true} widgets={[ <Sharing @@ -325,7 +423,7 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { />, ]} > - <Overview cover={cover} meta={overviewData} /> + <Overview cover={cover} meta={filteredOverviewMeta} /> <ProjectContent components={components} /> </PageLayout> </> diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx index 97963dd..44354ce 100644 --- a/src/pages/projets/index.tsx +++ b/src/pages/projets/index.tsx @@ -1,8 +1,10 @@ +/* eslint-disable max-statements */ import type { MDXComponents } from 'mdx/types'; import type { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import { useIntl } from 'react-intl'; import { CardsList, type CardsListItem, @@ -44,6 +46,12 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => { title, url: ROUTES.PROJECTS, }); + const intl = useIntl(); + const metaLabel = intl.formatMessage({ + defaultMessage: 'Technologies:', + description: 'Meta: technologies label', + id: 'ADQmDF', + }); const items: CardsListItem[] = projects.map( ({ id, meta: projectMeta, slug, title: projectTitle }) => { @@ -52,7 +60,17 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => { return { cover, id: id as string, - meta: { technologies }, + meta: technologies?.length + ? [ + { + id: 'technologies', + label: metaLabel, + value: technologies.map((techno) => { + return { id: techno, value: techno }; + }), + }, + ] + : [], tagline, title: projectTitle, url: `${ROUTES.PROJECTS}/${slug}`, diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index f47e40c..32312ec 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -9,6 +9,7 @@ import { getLayout, Heading, LinksListWidget, + type MetaItemData, Notice, PageLayout, PostsList, @@ -133,6 +134,28 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ getTotalArticles(query.s as string) ); + const headerMeta: MetaItemData[] = totalArticles + ? [ + { + id: 'posts-count', + label: intl.formatMessage({ + defaultMessage: 'Total:', + description: 'Page: total label', + id: 'kNBXyK', + }), + value: intl.formatMessage( + { + defaultMessage: + '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', + description: 'Page: posts count meta', + id: 'RvGb2c', + }, + { postsCount: totalArticles } + ), + }, + ] + : []; + /** * Load more posts handler. */ @@ -181,7 +204,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ title={title} breadcrumb={breadcrumbItems} breadcrumbSchema={breadcrumbSchema} - headerMeta={{ total: totalArticles }} + headerMeta={headerMeta} widgets={[ <LinksListWidget heading={ diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 899f9e1..cacc972 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -10,9 +10,9 @@ import { getLayout, Heading, LinksListWidget, + type MetaItemData, PageLayout, PostsList, - type MetaData, } from '../../components'; import { getAllTopicsSlugs, @@ -24,6 +24,7 @@ import styles from '../../styles/pages/topic.module.scss'; import type { NextPageWithLayout, PageLink, Topic } from '../../types'; import { ROUTES } from '../../utils/constants'; import { + getFormattedDate, getLinksListItems, getPageLinkFromRawData, getPostsWithUrl, @@ -59,13 +60,74 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ url: `${ROUTES.TOPICS}/${slug}`, }); - const headerMeta: MetaData = { - publication: { date: dates.publication }, - update: dates.update ? { date: dates.update } : undefined, - website: officialWebsite, - total: articles ? articles.length : undefined, + /** + * Retrieve a formatted date (and time). + * + * @param {string} date - A date string. + * @returns {JSX.Element} The formatted date wrapped in a time element. + */ + const getDate = (date: string): JSX.Element => { + const isoDate = new Date(`${date}`).toISOString(); + + return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; }; + const headerMeta: (MetaItemData | undefined)[] = [ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'TopicPage: publication date label', + id: 'KV+NMZ', + }), + value: getDate(dates.publication), + }, + dates.update + ? { + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'TopicPage: update date label', + id: '9DfuHk', + }), + value: getDate(dates.update), + } + : undefined, + officialWebsite + ? { + id: 'website', + label: intl.formatMessage({ + defaultMessage: 'Official website:', + description: 'TopicPage: official website label', + id: 'zoifQd', + }), + value: officialWebsite, + } + : undefined, + articles?.length + ? { + id: 'total', + label: intl.formatMessage({ + defaultMessage: 'Total:', + description: 'TopicPage: total label', + id: 'tBX4mb', + }), + value: intl.formatMessage( + { + defaultMessage: + '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', + description: 'TopicPage: posts count meta', + id: 'uAL4iW', + }, + { postsCount: articles.length } + ), + } + : undefined, + ]; + const filteredMeta = headerMeta.filter( + (item): item is MetaItemData => !!item + ); + const { website } = useSettings(); const { asPath } = useRouter(); const webpageSchema = getWebPageSchema({ @@ -132,7 +194,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ breadcrumbSchema={breadcrumbSchema} title={getPageHeading()} intro={intro} - headerMeta={headerMeta} + headerMeta={filteredMeta} widgets={ thematics ? [ diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index 95b4780..a5badf3 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -9,9 +9,9 @@ import { getLayout, Heading, LinksListWidget, + type MetaItemData, PageLayout, PostsList, - type MetaData, } from '../../components'; import { getAllThematicsSlugs, @@ -22,6 +22,7 @@ import { import type { NextPageWithLayout, PageLink, Thematic } from '../../types'; import { ROUTES } from '../../utils/constants'; import { + getFormattedDate, getLinksListItems, getPageLinkFromRawData, getPostsWithUrl, @@ -50,12 +51,63 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ url: `${ROUTES.THEMATICS.INDEX}/${slug}`, }); - const headerMeta: MetaData = { - publication: { date: dates.publication }, - update: dates.update ? { date: dates.update } : undefined, - total: articles ? articles.length : undefined, + /** + * Retrieve a formatted date (and time). + * + * @param {string} date - A date string. + * @returns {JSX.Element} The formatted date wrapped in a time element. + */ + const getDate = (date: string): JSX.Element => { + const isoDate = new Date(`${date}`).toISOString(); + + return <time dateTime={isoDate}>{getFormattedDate(date)}</time>; }; + const headerMeta: (MetaItemData | undefined)[] = [ + { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'ThematicPage: publication date label', + id: 'UTGhUU', + }), + value: getDate(dates.publication), + }, + dates.update + ? { + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'ThematicPage: update date label', + id: '24FIsG', + }), + value: getDate(dates.update), + } + : undefined, + articles + ? { + id: 'total', + label: intl.formatMessage({ + defaultMessage: 'Total:', + description: 'ThematicPage: total label', + id: 'lHkta9', + }), + value: intl.formatMessage( + { + defaultMessage: + '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', + description: 'ThematicPage: posts count meta', + id: 'iv3Ex1', + }, + { postsCount: articles.length } + ), + } + : undefined, + ]; + const filteredMeta = headerMeta.filter( + (item): item is MetaItemData => !!item + ); + const { website } = useSettings(); const { asPath } = useRouter(); const webpageSchema = getWebPageSchema({ @@ -114,7 +166,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ breadcrumbSchema={breadcrumbSchema} title={title} intro={intro} - headerMeta={headerMeta} + headerMeta={filteredMeta} widgets={ topics ? [ diff --git a/src/styles/abstracts/placeholders/_lists.scss b/src/styles/abstracts/placeholders/_lists.scss index 780fd21..2200336 100644 --- a/src/styles/abstracts/placeholders/_lists.scss +++ b/src/styles/abstracts/placeholders/_lists.scss @@ -75,5 +75,5 @@ %description { margin: 0; - word-break: break-all; + overflow-wrap: break-word; } diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss index 068826f..d2e7822 100644 --- a/src/styles/pages/article.module.scss +++ b/src/styles/pages/article.module.scss @@ -12,9 +12,8 @@ margin-right: var(--spacing-2xs); padding: var(--spacing-2xs) var(--spacing-xs); - figure { + img { max-width: fun.convert-px(22); - margin-right: var(--spacing-2xs); } } |
