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 | |
| 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...).
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); } } |
