diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-06 18:08:04 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | c9c1c90b30e243563bb4f731da15b3fe657556d2 (patch) | |
| tree | 8263c176b4096e2893b9d9319bfa7edb01fce188 /src | |
| parent | 2771de88f40a5f4ed7480bd8614532dda72deeda (diff) | |
refactor(components): replace Summary component with PostPreview
* rename component to PostPreview because Summary is an HTML element
and it could lead to confusion
* replace `title` and `titleLevel` with `heading` and `headingLvl`
because `title` is a native attribute
* rename `intro` prop to `excerpt`
* extract `cover` from `meta` prop
* rewrite meta type
* extract meta logic into a new component
Diffstat (limited to 'src')
23 files changed, 997 insertions, 539 deletions
diff --git a/src/components/molecules/card/card.module.scss b/src/components/molecules/card/card.module.scss index 65f92f6..d87114b 100644 --- a/src/components/molecules/card/card.module.scss +++ b/src/components/molecules/card/card.module.scss @@ -232,9 +232,15 @@ $breakpoint: 50ch; grid-column: 3; } - :where(.footer) .meta { - grid-row-start: 1; - flex-flow: column wrap; + :where(.footer) { + .actions { + padding-bottom: 0; + } + + .meta { + grid-row-start: 1; + flex-flow: column wrap; + } } :where(.body:first-child + .footer) .meta, diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 092b78e..43414fa 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -2,4 +2,5 @@ export * from './forms'; export * from './layout'; export * from './nav'; export * from './navbar'; +export * from './post-preview'; export * from './widgets'; diff --git a/src/components/organisms/layout/index.ts b/src/components/organisms/layout/index.ts index 86670e3..552ed27 100644 --- a/src/components/organisms/layout/index.ts +++ b/src/components/organisms/layout/index.ts @@ -3,4 +3,3 @@ export * from './comments-list'; export * from './no-results'; export * from './overview'; export * from './posts-list'; -export * from './summary'; diff --git a/src/components/organisms/layout/posts-list.fixture.ts b/src/components/organisms/layout/posts-list.fixture.tsx index dfb0d97..e1f7a95 100644 --- a/src/components/organisms/layout/posts-list.fixture.ts +++ b/src/components/organisms/layout/posts-list.fixture.tsx @@ -1,4 +1,5 @@ -import type { Post } from './posts-list'; +import NextImage from 'next/image'; +import type { PostData } from './posts-list'; export const introPost1 = 'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.'; @@ -16,46 +17,46 @@ export const cover = { width: 640, }; -export const posts: Post[] = [ +export const posts: PostData[] = [ { - intro: introPost1, + cover: <NextImage {...cover} />, + excerpt: introPost1, id: 'post-1', meta: { - cover, - dates: { publication: '2022-02-26' }, + publicationDate: '2022-02-26', wordsCount: introPost1.split(' ').length, thematics: [ { id: 1, name: 'Cat 1', url: '#' }, { id: 2, name: 'Cat 2', url: '#' }, ], - commentsCount: 1, + comments: { count: 1, postHeading: 'Ratione velit fuga' }, }, - title: 'Ratione velit fuga', + heading: 'Ratione velit fuga', url: '#', }, { - intro: introPost2, + excerpt: introPost2, id: 'post-2', meta: { - dates: { publication: '2022-02-20' }, + publicationDate: '2022-02-20', wordsCount: introPost2.split(' ').length, thematics: [{ id: 2, name: 'Cat 2', url: '#' }], - commentsCount: 0, + comments: { count: 0, postHeading: 'Debitis laudantium laudantium' }, }, - title: 'Debitis laudantium laudantium', + heading: 'Debitis laudantium laudantium', url: '#', }, { - intro: introPost3, + cover: <NextImage {...cover} />, + excerpt: introPost3, id: 'post-3', meta: { - cover, - dates: { publication: '2021-12-20' }, + publicationDate: '2021-12-20', wordsCount: introPost3.split(' ').length, thematics: [{ id: 1, name: 'Cat 1', url: '#' }], - commentsCount: 3, + comments: { count: 3, postHeading: 'Quaerat ut corporis' }, }, - title: 'Quaerat ut corporis', + heading: 'Quaerat ut corporis', url: '#', }, ]; diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx index 36d3c87..40306a6 100644 --- a/src/components/organisms/layout/posts-list.tsx +++ b/src/components/organisms/layout/posts-list.tsx @@ -17,18 +17,30 @@ import { type RenderPaginationItemAriaLabel, type RenderPaginationLink, } from '../nav'; +import { + PostPreview, + type PostPreviewMetaData, + type PostPreviewProps, +} from '../post-preview'; import { NoResults } from './no-results'; import styles from './posts-list.module.scss'; -import { Summary, type SummaryProps } from './summary'; -export type Post = Omit<SummaryProps, 'titleLevel'> & { +export type PostData = Pick< + PostPreviewProps, + 'cover' | 'excerpt' | 'heading' | 'url' +> & { /** * The post id. */ id: string | number; + /** + * The post meta. + */ + meta: PostPreviewMetaData & + Required<Pick<PostPreviewMetaData, 'publicationDate'>>; }; -export type YearCollection = Record<string, Post[]>; +export type YearCollection = Record<string, PostData[]>; export type PostsListProps = Pick<PaginationProps, 'siblings'> & { /** @@ -54,7 +66,7 @@ export type PostsListProps = Pick<PaginationProps, 'siblings'> & { /** * The posts data. */ - posts: Post[]; + posts: PostData[]; /** * Determine if the load more button should be visible. */ @@ -72,14 +84,14 @@ export type PostsListProps = Pick<PaginationProps, 'siblings'> & { /** * Create a collection of posts sorted by year. * - * @param {Posts[]} data - A collection of posts. + * @param {PostData[]} data - A collection of posts. * @returns {YearCollection} The posts sorted by year. */ -const sortPostsByYear = (data: Post[]): YearCollection => { +const sortPostsByYear = (data: PostData[]): YearCollection => { const yearCollection: Partial<YearCollection> = {}; data.forEach((post) => { - const postYear = new Date(post.meta.dates.publication) + const postYear = new Date(post.meta.publicationDate) .getFullYear() .toString(); yearCollection[postYear] = [...(yearCollection[postYear] ?? []), post]; @@ -116,12 +128,12 @@ export const PostsList: FC<PostsListProps> = ({ /** * Retrieve the list of posts. * - * @param {Posts[]} allPosts - A collection fo posts. + * @param {PostData[]} allPosts - A collection fo posts. * @param {HeadingLevel} [headingLevel] - The posts heading level (hn). * @returns {JSX.Element} The list of posts. */ const getList = ( - allPosts: Post[], + allPosts: PostData[], headingLevel: HeadingLevel = 2 ): JSX.Element => ( <List @@ -136,7 +148,7 @@ export const PostsList: FC<PostsListProps> = ({ {allPosts.map(({ id, ...post }) => ( <Fragment key={id}> <ListItem className={styles.item}> - <Summary {...post} titleLevel={headingLevel} /> + <PostPreview {...post} headingLvl={headingLevel} /> </ListItem> {id === lastPostId && ( <ListItem> diff --git a/src/components/organisms/layout/summary.fixture.ts b/src/components/organisms/layout/summary.fixture.ts deleted file mode 100644 index 6f90b4a..0000000 --- a/src/components/organisms/layout/summary.fixture.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SummaryMeta } from './summary'; - -export const cover = { - alt: 'A cover', - height: 480, - src: 'https://picsum.photos/640/480', - width: 640, -}; - -export const intro = - 'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.'; - -export const meta: SummaryMeta = { - dates: { publication: '2022-04-11' }, - wordsCount: intro.split(' ').length, - thematics: [ - { id: 1, name: 'Cat 1', url: '#' }, - { id: 2, name: 'Cat 2', url: '#' }, - ], - commentsCount: 1, -}; - -export const title = 'Odio odit necessitatibus'; - -export const url = '#'; diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx deleted file mode 100644 index fe8b704..0000000 --- a/src/components/organisms/layout/summary.stories.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Summary } from './summary'; -import { cover, intro, meta } from './summary.fixture'; - -/** - * Summary - Storybook Meta - */ -export default { - title: 'Organisms/Layout/Summary', - component: Summary, - args: { - titleLevel: 2, - }, - argTypes: { - cover: { - description: 'The cover data.', - table: { - category: 'Options', - }, - type: { - name: 'object', - required: false, - value: {}, - }, - }, - excerpt: { - control: { - type: 'text', - }, - description: 'The page excerpt.', - type: { - name: 'string', - required: true, - }, - }, - meta: { - description: 'The page metadata.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - title: { - control: { - type: 'text', - }, - description: 'The page title', - type: { - name: 'string', - required: true, - }, - }, - titleLevel: { - control: { - type: 'number', - min: 1, - max: 6, - }, - description: 'The page title level (hn)', - table: { - category: 'Options', - defaultValue: { summary: 2 }, - }, - type: { - name: 'number', - required: false, - }, - }, - url: { - control: { - type: 'text', - }, - description: 'The page url.', - type: { - name: 'string', - required: true, - }, - }, - }, -} as ComponentMeta<typeof Summary>; - -const Template: ComponentStory<typeof Summary> = (args) => ( - <Summary {...args} /> -); - -/** - * Summary Stories - Default - */ -export const Default = Template.bind({}); -Default.args = { - intro, - meta, - title: 'Odio odit necessitatibus', - url: '#', -}; - -/** - * Summary Stories - With cover - */ -export const WithCover = Template.bind({}); -WithCover.args = { - intro, - meta: { ...meta, cover }, - title: 'Odio odit necessitatibus', - url: '#', -}; diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx deleted file mode 100644 index 3e58e9a..0000000 --- a/src/components/organisms/layout/summary.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Summary } from './summary'; -import { cover, intro, meta, title, url } from './summary.fixture'; - -describe('Summary', () => { - it('renders a title wrapped in a h2 element', () => { - render( - <Summary - intro={intro} - meta={meta} - title={title} - titleLevel={2} - url={url} - /> - ); - expect( - screen.getByRole('heading', { level: 2, name: title }) - ).toBeInTheDocument(); - }); - - it('renders an excerpt', () => { - render(<Summary intro={intro} meta={meta} title={title} url={url} />); - expect(screen.getByText(intro)).toBeInTheDocument(); - }); - - it('renders a cover', () => { - render( - <Summary - intro={intro} - meta={{ ...meta, cover }} - title={title} - url={url} - /> - ); - expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); - }); - - it('renders a link to the full post', () => { - render(<Summary intro={intro} meta={meta} title={title} url={url} />); - expect(screen.getByRole('link', { name: title })).toBeInTheDocument(); - }); - - it('renders a read more link', () => { - render(<Summary intro={intro} meta={meta} title={title} url={url} />); - expect( - screen.getByRole('link', { name: `Read more about ${title}` }) - ).toBeInTheDocument(); - }); - - it('renders some meta', () => { - render(<Summary intro={intro} meta={meta} title={title} url={url} />); - expect(screen.getByText(meta.thematics![0].name)).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx deleted file mode 100644 index 0c95f90..0000000 --- a/src/components/organisms/layout/summary.tsx +++ /dev/null @@ -1,235 +0,0 @@ -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 { useReadingTime } from '../../../utils/hooks'; -import { ButtonLink, type HeadingLevel, Icon, Link, Time } from '../../atoms'; -import { - Card, - CardActions, - CardBody, - CardCover, - CardFooter, - CardHeader, - CardMeta, - CardTitle, - type MetaItemData, -} from '../../molecules'; -import styles from './summary.module.scss'; - -export type Cover = Pick<NextImageProps, 'alt' | 'src' | 'width' | 'height'>; - -export type SummaryMeta = Pick< - MetaType<'article'>, - | 'author' - | 'commentsCount' - | 'cover' - | 'dates' - | 'thematics' - | 'topics' - | 'wordsCount' ->; - -export type SummaryProps = Pick<Article, 'intro' | 'title'> & { - /** - * The post metadata. - */ - meta: SummaryMeta; - /** - * The heading level (hn). - */ - titleLevel?: HeadingLevel; - /** - * The post url. - */ - url: string; -}; - -/** - * Summary component - * - * Render a page summary. - */ -export const Summary: FC<SummaryProps> = ({ - intro, - meta, - title, - titleLevel = 2, - url, -}) => { - const intl = useIntl(); - const figureLabel = intl.formatMessage( - { - defaultMessage: '{title} cover', - description: 'Summary: figure (cover) accessible name', - id: 'RNVe1W', - }, - { title } - ); - const readMore = intl.formatMessage( - { - defaultMessage: 'Read more<a11y> about {title}</a11y>', - description: 'Summary: read more link', - id: 'Zpgv+f', - }, - { - title, - a11y: (chunks: ReactNode) => ( - // eslint-disable-next-line react/jsx-no-literals -- SR class allowed - <span className="screen-reader-text">{chunks}</span> - ), - } - ); - const readingTime = useReadingTime(meta.wordsCount, true); - - const getMetaItems = (): MetaItemData[] => { - const summaryMeta: MetaItemData[] = [ - { - id: 'publication-date', - label: intl.formatMessage({ - defaultMessage: 'Published on:', - description: 'Summary: publication date label', - id: 'TvQ2Ee', - }), - value: <Time date={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: <Time date={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 ( - <Card - className={styles.wrapper} - cover={ - meta.cover ? ( - <CardCover aria-label={figureLabel} hasBorders> - <NextImage {...meta.cover} /> - </CardCover> - ) : undefined - } - meta={<CardMeta items={getMetaItems()} />} - > - <CardHeader> - <CardTitle className={styles.title} level={titleLevel}> - <Link href={url}>{title}</Link> - </CardTitle> - </CardHeader> - <CardBody> - <div - className={styles.intro} - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ __html: intro }} - /> - </CardBody> - <CardFooter> - <CardActions> - <ButtonLink 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" - /> - </ButtonLink> - </CardActions> - </CardFooter> - </Card> - ); -}; diff --git a/src/components/organisms/post-preview/index.ts b/src/components/organisms/post-preview/index.ts new file mode 100644 index 0000000..8c49301 --- /dev/null +++ b/src/components/organisms/post-preview/index.ts @@ -0,0 +1,2 @@ +export * from './post-preview'; +export type { PostPreviewMetaData } from './post-preview-meta'; diff --git a/src/components/organisms/post-preview/post-preview-meta/index.ts b/src/components/organisms/post-preview/post-preview-meta/index.ts new file mode 100644 index 0000000..0305db3 --- /dev/null +++ b/src/components/organisms/post-preview/post-preview-meta/index.ts @@ -0,0 +1 @@ +export * from './post-preview-meta'; diff --git a/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.test.tsx b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.test.tsx new file mode 100644 index 0000000..188afcc --- /dev/null +++ b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.test.tsx @@ -0,0 +1,141 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { getReadingTimeFrom } from '../../../../utils/helpers'; +import { PostPreviewMeta, type PostPreviewMetaData } from './post-preview-meta'; + +describe('PostPreviewMeta', () => { + it('can render a meta for the author', () => { + const meta = { + author: 'Gilberto70', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Written by:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.author); + }); + + it('can render a meta for a single comment', () => { + const meta = { + comments: { + count: 1, + postHeading: 'quae commodi deserunt', + }, + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Comments:'); + expect(rtlScreen.getByRole('definition').textContent).toBe( + `${meta.comments.count} comment about ${meta.comments.postHeading}` + ); + }); + + it('can render a meta for comments with a link', () => { + const meta = { + comments: { + count: 3, + postHeading: 'quae commodi deserunt', + url: '#temporibus', + }, + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Comments:'); + expect(rtlScreen.getByRole('definition').textContent).toBe( + `${meta.comments.count} comments about ${meta.comments.postHeading}` + ); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + meta.comments.url + ); + }); + + it('can render a meta for the publication date', () => { + const meta = { + publicationDate: '2002', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Published on:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.publicationDate + ); + }); + + it('can render a meta for thematics', () => { + const meta = { + thematics: [{ id: 1, name: 'autem', url: '#est' }], + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Thematic:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.thematics[0].name + ); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + meta.thematics[0].url + ); + }); + + it('can render a meta for topics', () => { + const meta = { + topics: [{ id: 1, name: 'hic', url: '#ipsa' }], + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Topic:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.topics[0].name + ); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + meta.topics[0].url + ); + }); + + it('can render a meta for the update date', () => { + const meta = { + updateDate: '2020', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Updated on:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.updateDate + ); + }); + + it('does not render a meta for the update date if it is equal to the publication date', () => { + const meta = { + publicationDate: '2020', + updateDate: '2020', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms.length).toBe(1); + expect(terms[0].textContent).toBe('Published on:'); + }); + + it('can render a meta for the reading time', () => { + const meta = { + wordsCount: 500, + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Reading time:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + `${getReadingTimeFrom(meta.wordsCount).inMinutes()} minutes` + ); + }); +}); diff --git a/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.tsx b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.tsx new file mode 100644 index 0000000..5a342da --- /dev/null +++ b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.tsx @@ -0,0 +1,254 @@ +import type { FC, ReactNode } from 'react'; +import { useIntl } from 'react-intl'; +import type { PageLink } from '../../../../types'; +import { getReadingTimeFrom } from '../../../../utils/helpers'; +import { Link, Time, VisuallyHidden } from '../../../atoms'; +import { + CardMeta, + type MetaItemData, + type CardMetaProps, +} from '../../../molecules'; + +const a11y = (chunks: ReactNode) => <VisuallyHidden>{chunks}</VisuallyHidden>; + +export type PostPreviewMetaComment = { + /** + * The number of comments. + */ + count: number; + /** + * The post heading (used to generate an accessible label). + */ + postHeading: string; + /** + * An url to the comments section. + */ + url?: string; +}; + +export type PostPreviewMetaData = { + /** + * The author name. + */ + author?: string; + /** + * The number of comments on the post and eventually an url to read them. + */ + comments?: PostPreviewMetaComment; + /** + * The publication date of the post. + */ + publicationDate?: string; + /** + * The thematics attached to the post. + */ + thematics?: PageLink[]; + /** + * The topics attached to the post. + */ + topics?: PageLink[]; + /** + * The last modification date of the post. + */ + updateDate?: string; + /** + * The number of words in the post. + */ + wordsCount?: number; +}; + +const validMetaKeys = [ + 'author', + 'comments', + 'publicationDate', + 'thematics', + 'topics', + 'updateDate', + 'wordsCount', +] satisfies (keyof PostPreviewMetaData)[]; + +const isValidMetaKey = (key: string): key is keyof PostPreviewMetaData => + (validMetaKeys as string[]).includes(key); + +export type PostPreviewMetaProps = Omit<CardMetaProps, 'items'> & { + /** + * The post meta. + */ + meta: PostPreviewMetaData; +}; + +export const PostPreviewMeta: FC<PostPreviewMetaProps> = ({ + meta, + ...props +}) => { + const intl = useIntl(); + + const getAuthor = (): MetaItemData | undefined => { + if (!meta.author) return undefined; + + return { + id: 'author', + label: intl.formatMessage({ + defaultMessage: 'Written by:', + description: 'PostPreviewMeta: author label', + id: '2U7ixo', + }), + value: meta.author, + }; + }; + + const getCommentsCount = (): MetaItemData | undefined => { + if (!meta.comments) return undefined; + + const commentsLabel = intl.formatMessage<ReactNode>( + { + defaultMessage: + '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>', + description: 'PostPreviewMeta: comments count', + id: 'NfAn9N', + }, + { + a11y, + commentsCount: meta.comments.count, + title: meta.comments.postHeading, + } + ); + + return { + id: 'comments', + label: intl.formatMessage({ + defaultMessage: 'Comments:', + description: 'PostPreviewMeta: comments label', + id: 'FCpPCm', + }), + value: meta.comments.url ? ( + <Link href={meta.comments.url}>{commentsLabel}</Link> + ) : ( + <>{commentsLabel}</> + ), + }; + }; + + const getPublicationDate = (): MetaItemData | undefined => { + if (!meta.publicationDate) return undefined; + + return { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'PostPreviewMeta: publication date label', + id: '+6f4p1', + }), + value: <Time date={meta.publicationDate} />, + }; + }; + + const getThematics = (): MetaItemData | undefined => { + if (!meta.thematics?.length) return undefined; + + return { + id: 'thematics', + label: intl.formatMessage( + { + defaultMessage: + '{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}', + description: 'PostPreviewMeta: thematics label', + id: '9MTBCG', + }, + { thematicsCount: meta.thematics.length } + ), + value: meta.thematics.map((thematic) => { + return { + id: `thematic-${thematic.id}`, + value: <Link href={thematic.url}>{thematic.name}</Link>, + }; + }), + }; + }; + + const getTopics = (): MetaItemData | undefined => { + if (!meta.topics?.length) return undefined; + + return { + id: 'topics', + label: intl.formatMessage( + { + defaultMessage: + '{topicsCount, plural, =0 {Topics:} one {Topic:} other {Topics:}}', + description: 'PostPreviewMeta: topics label', + id: 'aBQYbE', + }, + { topicsCount: meta.topics.length } + ), + value: meta.topics.map((topic) => { + return { + id: `topic-${topic.id}`, + value: <Link href={topic.url}>{topic.name}</Link>, + }; + }), + }; + }; + + const getUpdateDate = (): MetaItemData | undefined => { + if (!meta.updateDate || meta.updateDate === meta.publicationDate) + return undefined; + + return { + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'PostPreviewMeta: update date label', + id: 'ZmRh0V', + }), + value: <Time date={meta.updateDate} />, + }; + }; + + const getReadingTime = (): MetaItemData | undefined => { + if (!meta.wordsCount) return undefined; + + return { + id: 'reading-time', + label: intl.formatMessage({ + defaultMessage: 'Reading time:', + description: 'PostPreviewMeta: reading time label', + id: 'B1lS/v', + }), + value: intl.formatMessage( + { + defaultMessage: + '{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}', + description: 'PostPreviewMeta: rounded minutes count', + id: 'y+13Ax', + }, + { minutesCount: getReadingTimeFrom(meta.wordsCount).inMinutes() } + ), + }; + }; + + const items: MetaItemData[] = Object.keys(meta) + .filter(isValidMetaKey) + .map((key): MetaItemData | undefined => { + switch (key) { + case 'author': + return getAuthor(); + case 'comments': + return getCommentsCount(); + case 'publicationDate': + return getPublicationDate(); + case 'thematics': + return getThematics(); + case 'topics': + return getTopics(); + case 'updateDate': + return getUpdateDate(); + case 'wordsCount': + return getReadingTime(); + default: + throw new Error('Unsupported meta key.'); + } + }) + .filter((item): item is MetaItemData => item !== undefined); + + return <CardMeta {...props} items={items} />; +}; diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/post-preview/post-preview.module.scss index 6e0af6a..2ff2e0e 100644 --- a/src/components/organisms/layout/summary.module.scss +++ b/src/components/organisms/post-preview/post-preview.module.scss @@ -10,11 +10,11 @@ } } -.title { +.heading { font-size: var(--font-size-2xl); } -.intro { +.excerpt { > *:last-child { margin-bottom: 0; } diff --git a/src/components/organisms/post-preview/post-preview.stories.tsx b/src/components/organisms/post-preview/post-preview.stories.tsx new file mode 100644 index 0000000..c22698f --- /dev/null +++ b/src/components/organisms/post-preview/post-preview.stories.tsx @@ -0,0 +1,157 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import NextImage from 'next/image'; +import { PostPreview } from './post-preview'; + +/** + * PostPreview - Storybook Meta + */ +export default { + title: 'Organisms/PostPreview', + component: PostPreview, + argTypes: { + cover: { + description: 'The cover data.', + table: { + category: 'Options', + }, + type: { + name: 'object', + required: false, + value: {}, + }, + }, + excerpt: { + control: { + type: 'text', + }, + description: 'The page excerpt.', + type: { + name: 'string', + required: true, + }, + }, + meta: { + description: 'The page metadata.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + heading: { + control: { + type: 'text', + }, + description: 'The page title', + type: { + name: 'string', + required: true, + }, + }, + headingLvl: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'The page title level (hn)', + table: { + category: 'Options', + defaultValue: { summary: 2 }, + }, + type: { + name: 'number', + required: false, + }, + }, + url: { + control: { + type: 'text', + }, + description: 'The page url.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof PostPreview>; + +const Template: ComponentStory<typeof PostPreview> = (args) => ( + <PostPreview {...args} /> +); + +/** + * PostPreview Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + url: '#post', +}; + +/** + * PostPreview Stories - WithCover + */ +export const WithCover = Template.bind({}); +WithCover.args = { + cover: ( + <NextImage + alt="" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + ), + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + url: '#post', +}; + +/** + * PostPreview Stories - WithMeta + */ +export const WithMeta = Template.bind({}); +WithMeta.args = { + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + meta: { + publicationDate: '06/11/2023', + thematics: [{ id: 1, name: 'Thematic 1', url: '#thematic' }], + wordsCount: 300, + }, + url: '#post', +}; + +/** + * PostPreview Stories - WithCoverAndMeta + */ +export const WithCoverAndMeta = Template.bind({}); +WithCoverAndMeta.args = { + cover: ( + <NextImage + alt="" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + ), + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + meta: { + publicationDate: '06/11/2023', + wordsCount: 300, + thematics: [{ id: 1, name: 'Thematic 1', url: '#thematic' }], + comments: { + count: 3, + postHeading: 'The post title', + url: '#comments', + }, + }, + url: '#post', +}; diff --git a/src/components/organisms/post-preview/post-preview.test.tsx b/src/components/organisms/post-preview/post-preview.test.tsx new file mode 100644 index 0000000..78012ed --- /dev/null +++ b/src/components/organisms/post-preview/post-preview.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@jest/globals'; +import NextImage from 'next/image'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { PostPreview } from './post-preview'; +import type { PostPreviewMetaData } from './post-preview-meta'; + +describe('PostPreview', () => { + it('renders an excerpt with a heading and a link', () => { + const excerpt = 'At necessitatibus id soluta adipisci quibusdam.'; + const heading = 'impedit et ea'; + const url = '#quia'; + + render(<PostPreview excerpt={excerpt} heading={heading} url={url} />); + + expect(rtlScreen.getByRole('heading')).toHaveTextContent(heading); + expect(rtlScreen.getByRole('link', { name: heading })).toHaveAttribute( + 'href', + url + ); + expect(rtlScreen.getByText(excerpt)).toBeInTheDocument(); + }); + + it('can render a cover', () => { + const excerpt = 'At necessitatibus id soluta adipisci quibusdam.'; + const heading = 'impedit et ea'; + const url = '#quia'; + const altTxt = 'alias consequatur quod'; + + render( + <PostPreview + cover={ + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + } + excerpt={excerpt} + heading={heading} + url={url} + /> + ); + + expect(rtlScreen.getByRole('img')).toHaveAccessibleName(altTxt); + }); + + it('can render some meta', () => { + const excerpt = 'At necessitatibus id soluta adipisci quibusdam.'; + const heading = 'impedit et ea'; + const url = '#quia'; + const meta = { + author: 'Noah_Gleason48', + publicationDate: '2023', + wordsCount: 250, + } satisfies PostPreviewMetaData; + + render( + <PostPreview excerpt={excerpt} heading={heading} meta={meta} url={url} /> + ); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + }); +}); diff --git a/src/components/organisms/post-preview/post-preview.tsx b/src/components/organisms/post-preview/post-preview.tsx new file mode 100644 index 0000000..df459e2 --- /dev/null +++ b/src/components/organisms/post-preview/post-preview.tsx @@ -0,0 +1,132 @@ +import { + type ForwardRefRenderFunction, + type ReactElement, + forwardRef, + type ReactNode, +} from 'react'; +import { useIntl } from 'react-intl'; +import { + ButtonLink, + type HeadingLevel, + Icon, + Link, + VisuallyHidden, +} from '../../atoms'; +import { + Card, + CardActions, + CardBody, + CardFooter, + CardHeader, + type CardProps, + CardTitle, + CardCover, +} from '../../molecules'; +import { PostPreviewMeta, type PostPreviewMetaData } from './post-preview-meta'; +import styles from './post-preview.module.scss'; + +const a11y = (chunks: ReactNode) => <VisuallyHidden>{chunks}</VisuallyHidden>; + +export type PostPreviewProps = Omit< + CardProps<undefined>, + 'children' | 'cover' | 'linkTo' | 'meta' | 'variant' +> & { + /** + * The post cover. + */ + cover?: ReactElement; + /** + * The post excerpt. + */ + excerpt: string; + /** + * The post title. + */ + heading: string; + /** + * The heading level to use on post title. + */ + headingLvl?: HeadingLevel; + /** + * The post meta. + */ + meta?: PostPreviewMetaData; + /** + * The post url. + */ + url: string; +}; + +const PostPreviewWithRef: ForwardRefRenderFunction< + HTMLDivElement, + PostPreviewProps +> = ( + { className, cover, excerpt, heading, headingLvl, meta, url, ...props }, + ref +) => { + const wrapperClass = `${styles.wrapper} ${className}`; + const intl = useIntl(); + const coverLabel = intl.formatMessage( + { + defaultMessage: '{postTitle} cover', + description: + 'PostPreview: an accessible name for the figure wrapping the cover', + id: 'iG5SHf', + }, + { postTitle: heading } + ); + const readMore = intl.formatMessage<ReactNode>( + { + defaultMessage: 'Read more<a11y> about {postTitle}</a11y>', + description: 'PostPreview: read more link', + id: 'BYdQze', + }, + { + postTitle: heading, + a11y, + } + ); + + return ( + <Card + {...props} + className={wrapperClass} + cover={ + cover ? ( + <CardCover aria-label={coverLabel} hasBorders> + {cover} + </CardCover> + ) : undefined + } + meta={meta ? <PostPreviewMeta meta={meta} /> : undefined} + ref={ref} + > + <CardHeader> + <CardTitle className={styles.heading} level={headingLvl}> + <Link href={url}>{heading}</Link> + </CardTitle> + </CardHeader> + <CardBody + className={styles.excerpt} + dangerouslySetInnerHTML={{ __html: excerpt }} + /> + <CardFooter> + <CardActions> + <ButtonLink to={url}> + {readMore} + <Icon + aria-hidden + className={styles.icon} + // eslint-disable-next-line react/jsx-no-literals + orientation="right" + // eslint-disable-next-line react/jsx-no-literals + shape="arrow" + /> + </ButtonLink> + </CardActions> + </CardFooter> + </Card> + ); +}; + +export const PostPreview = forwardRef(PostPreviewWithRef); diff --git a/src/i18n/en.json b/src/i18n/en.json index ebeed8b..4b649cc 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -3,6 +3,10 @@ "defaultMessage": "Gitlab profile", "description": "CVPage: Gitlab profile link" }, + "+6f4p1": { + "defaultMessage": "Published on:", + "description": "PostPreviewMeta: publication date label" + }, "+Dre5J": { "defaultMessage": "Open-source projects", "description": "CVPage: social media widget title" @@ -63,6 +67,10 @@ "defaultMessage": "Dark theme", "description": "ThemeToggle: dark theme label" }, + "2U7ixo": { + "defaultMessage": "Written by:", + "description": "PostPreviewMeta: author label" + }, "2fD5CI": { "defaultMessage": "Leave a reply", "description": "Comment: comment form title" @@ -151,6 +159,10 @@ "defaultMessage": "Updated on:", "description": "TopicPage: update date label" }, + "9MTBCG": { + "defaultMessage": "{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}", + "description": "PostPreviewMeta: thematics label" + }, "9MeLN3": { "defaultMessage": "{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}", "description": "PostsList: loaded articles progress" @@ -179,6 +191,10 @@ "defaultMessage": "Go to page {number}", "description": "PostsList: pagination page link label" }, + "B1lS/v": { + "defaultMessage": "Reading time:", + "description": "PostPreviewMeta: reading time label" + }, "B290Ph": { "defaultMessage": "Thanks, your comment was successfully sent.", "description": "PageLayout: comment form success message" @@ -187,6 +203,10 @@ "defaultMessage": "Others formats", "description": "CVPage: cv preview widget title" }, + "BYdQze": { + "defaultMessage": "Read more<a11y> about {postTitle}</a11y>", + "description": "PostPreview: read more link" + }, "Bh7z5v": { "defaultMessage": "Email:", "description": "CommentForm: email label" @@ -231,6 +251,10 @@ "defaultMessage": "Updated on:", "description": "Page: update date label" }, + "FCpPCm": { + "defaultMessage": "Comments:", + "description": "PostPreviewMeta: comments label" + }, "FdF33B": { "defaultMessage": "{title} cover", "description": "ProjectsPage: figure (cover) accessible name" @@ -327,6 +351,10 @@ "defaultMessage": "Share on Twitter", "description": "Sharing: Twitter sharing link" }, + "NfAn9N": { + "defaultMessage": "{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>", + "description": "PostPreviewMeta: comments count" + }, "Nx8Jo5": { "defaultMessage": "Github profile", "description": "ProjectsPage: Github profile link" @@ -367,10 +395,6 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, - "RNVe1W": { - "defaultMessage": "{title} cover", - "description": "Summary: figure (cover) accessible name" - }, "RecdwX": { "defaultMessage": "Published on:", "description": "ArticlePage: publication date label" @@ -399,10 +423,6 @@ "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" @@ -475,9 +495,13 @@ "defaultMessage": "Search results for {query}", "description": "SearchPage: SEO - Page title" }, - "Zpgv+f": { - "defaultMessage": "Read more<a11y> about {title}</a11y>", - "description": "Summary: read more link" + "ZmRh0V": { + "defaultMessage": "Updated on:", + "description": "PostPreviewMeta: update date label" + }, + "aBQYbE": { + "defaultMessage": "{topicsCount, plural, =0 {Topics:} one {Topic:} other {Topics:}}", + "description": "PostPreviewMeta: topics label" }, "azgQuH": { "defaultMessage": "You should read {title}", @@ -487,14 +511,6 @@ "defaultMessage": "Contact form", "description": "Contact: form accessible name" }, - "bfPp0g": { - "defaultMessage": "Comments:", - "description": "Summary: comments label" - }, - "bk0WOp": { - "defaultMessage": "Thematics:", - "description": "Summary: thematics label" - }, "bojYF5": { "defaultMessage": "Home", "description": "Layout: main nav - home link" @@ -507,10 +523,6 @@ "defaultMessage": "Main navigation", "description": "Layout: main nav accessible name" }, - "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" @@ -555,6 +567,10 @@ "defaultMessage": "Repositories:", "description": "ProjectsPage: repositories label" }, + "iG5SHf": { + "defaultMessage": "{postTitle} cover", + "description": "PostPreview: an accessible name for the figure wrapping the cover" + }, "iv3Ex1": { "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}", "description": "ThematicPage: posts count meta" @@ -655,10 +671,6 @@ "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" @@ -707,10 +719,6 @@ "defaultMessage": "Light theme", "description": "PrismThemeToggle: light theme label" }, - "tyzdql": { - "defaultMessage": "Reading time:", - "description": "Summary: reading time label" - }, "u41qSk": { "defaultMessage": "Website:", "description": "CommentForm: website label" @@ -759,22 +767,18 @@ "defaultMessage": "Sending mail...", "description": "ContactForm: spinner message on submit" }, + "y+13Ax": { + "defaultMessage": "{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}", + "description": "PostPreviewMeta: rounded minutes count" + }, "yB1SPF": { "defaultMessage": "CC BY SA", "description": "Layout: copyright title" }, - "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" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index fa2a65f..25991d4 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -3,6 +3,10 @@ "defaultMessage": "Profil Gitlab", "description": "CVPage: Gitlab profile link" }, + "+6f4p1": { + "defaultMessage": "Publié le :", + "description": "PostPreviewMeta: publication date label" + }, "+Dre5J": { "defaultMessage": "Projets open-source", "description": "CVPage: social media widget title" @@ -63,6 +67,10 @@ "defaultMessage": "Thème sombre", "description": "ThemeToggle: dark theme label" }, + "2U7ixo": { + "defaultMessage": "Écrit par :", + "description": "PostPreviewMeta: author label" + }, "2fD5CI": { "defaultMessage": "Laisser une réponse", "description": "Comment: comment form title" @@ -151,6 +159,10 @@ "defaultMessage": "Mis à jour le :", "description": "TopicPage: update date label" }, + "9MTBCG": { + "defaultMessage": "{thematicsCount, plural, =0 {Thématiques :} one {Thématique :} other {Thématiques :}}", + "description": "PostPreviewMeta: thematics label" + }, "9MeLN3": { "defaultMessage": "{articlesCount, plural, =0 {# article chargé} one {# article chargé} other {# articles chargés}} sur un total de {total}", "description": "PostsList: loaded articles progress" @@ -179,6 +191,10 @@ "defaultMessage": "Aller à la page {number}", "description": "PostsList: pagination page link label" }, + "B1lS/v": { + "defaultMessage": "Temps de lecture :", + "description": "PostPreviewMeta: reading time label" + }, "B290Ph": { "defaultMessage": "Merci, votre commentaire a été envoyé avec succès.", "description": "PageLayout: comment form success message" @@ -187,6 +203,10 @@ "defaultMessage": "Autres formats", "description": "CVPage: cv preview widget title" }, + "BYdQze": { + "defaultMessage": "En lire plus<a11y> à propos de {postTitle}</a11y>", + "description": "PostPreview: read more link" + }, "Bh7z5v": { "defaultMessage": "E-mail :", "description": "CommentForm: email label" @@ -231,6 +251,10 @@ "defaultMessage": "Mis à jour le :", "description": "Page: update date label" }, + "FCpPCm": { + "defaultMessage": "Commentaires :", + "description": "PostPreviewMeta: comments label" + }, "FdF33B": { "defaultMessage": "Illustration de {title}", "description": "ProjectsPage: figure (cover) accessible name" @@ -327,6 +351,10 @@ "defaultMessage": "Partager sur Twitter", "description": "Sharing: Twitter sharing link" }, + "NfAn9N": { + "defaultMessage": "{commentsCount, plural, =0 {0 commentaire} one {# commentaire} other {# commentaires}}<a11y> à propos de {title}</a11y>", + "description": "PostPreviewMeta: comments count" + }, "Nx8Jo5": { "defaultMessage": "Profil Github", "description": "ProjectsPage: Github profile link" @@ -367,10 +395,6 @@ "defaultMessage": "CV", "description": "Layout: main nav - cv link" }, - "RNVe1W": { - "defaultMessage": "Illustration de {title}", - "description": "Summary: figure (cover) accessible name" - }, "RecdwX": { "defaultMessage": "Publié le :", "description": "ArticlePage: publication date label" @@ -399,10 +423,6 @@ "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" @@ -475,9 +495,13 @@ "defaultMessage": "Résultats de la recherche pour {query}", "description": "SearchPage: SEO - Page title" }, - "Zpgv+f": { - "defaultMessage": "En lire plus<a11y> à propos de {title}</a11y>", - "description": "Summary: read more link" + "ZmRh0V": { + "defaultMessage": "Mis à jour le :", + "description": "PostPreviewMeta: update date label" + }, + "aBQYbE": { + "defaultMessage": "{topicsCount, plural, =0 {Sujets :} one {Sujet :} other {Sujets :}}", + "description": "PostPreviewMeta: topics label" }, "azgQuH": { "defaultMessage": "Vous devriez lire {title}", @@ -487,14 +511,6 @@ "defaultMessage": "Formulaire de contact", "description": "Contact: form accessible name" }, - "bfPp0g": { - "defaultMessage": "Commentaires :", - "description": "Summary: comments label" - }, - "bk0WOp": { - "defaultMessage": "Thématiques :", - "description": "Summary: thematics label" - }, "bojYF5": { "defaultMessage": "Accueil", "description": "Layout: main nav - home link" @@ -507,10 +523,6 @@ "defaultMessage": "Navigation principale", "description": "Layout: main nav accessible name" }, - "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" @@ -555,6 +567,10 @@ "defaultMessage": "Dépôts :", "description": "ProjectsPage: repositories label" }, + "iG5SHf": { + "defaultMessage": "Illustration de {postTitle}", + "description": "PostPreview: an accessible name for the figure wrapping the cover" + }, "iv3Ex1": { "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}", "description": "ThematicPage: posts count meta" @@ -655,10 +671,6 @@ "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" @@ -707,10 +719,6 @@ "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" @@ -759,22 +767,18 @@ "defaultMessage": "Mail en cours d’envoi…", "description": "ContactForm: spinner message on submit" }, + "y+13Ax": { + "defaultMessage": "{minutesCount, plural, =0 {Moins d’une minute} one {# minute} other {# minutes}}", + "description": "PostPreviewMeta: rounded minutes count" + }, "yB1SPF": { "defaultMessage": "CC BY SA", "description": "Layout: copyright title" }, - "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" diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts index 14487e6..f340a49 100644 --- a/src/utils/helpers/index.ts +++ b/src/utils/helpers/index.ts @@ -1,6 +1,7 @@ export * from './author'; export * from './images'; export * from './pages'; +export * from './reading-time'; export * from './rss'; export * from './schema-org'; export * from './strings'; diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.tsx index 84854cd..556b4fb 100644 --- a/src/utils/helpers/pages.ts +++ b/src/utils/helpers/pages.tsx @@ -1,4 +1,5 @@ -import type { LinksListItems, Post } from '../../components'; +import NextImage from 'next/image'; +import type { LinksListItems, PostData } from '../../components'; import { getArticleFromRawData } from '../../services/graphql'; import type { Article, @@ -72,13 +73,32 @@ export const getLinksListItems = (links: PageLink[]): LinksListItems[] => * Retrieve the posts list with the article URL. * * @param {Article[]} posts - An array of articles. - * @returns {Post[]} An array of posts with full article URL. + * @returns {PostData[]} An array of posts with full article URL. */ -export const getPostsWithUrl = (posts: Article[]): Post[] => - posts.map((post) => { +export const getPostsWithUrl = (posts: Article[]): PostData[] => + posts.map(({ intro, meta, slug, title, ...post }) => { return { ...post, - url: `/article/${post.slug}`, + cover: meta.cover ? <NextImage {...meta.cover} /> : undefined, + excerpt: intro, + heading: title, + meta: { + publicationDate: meta.dates.publication, + updateDate: meta.dates.update, + wordsCount: meta.wordsCount, + author: meta.author?.name, + thematics: meta.thematics, + topics: meta.topics, + comments: + meta.commentsCount === undefined + ? undefined + : { + count: meta.commentsCount, + postHeading: title, + url: `${ROUTES.ARTICLE}/${slug}#comments`, + }, + }, + url: `${ROUTES.ARTICLE}/${slug}`, }; }); @@ -86,9 +106,11 @@ export const getPostsWithUrl = (posts: Article[]): Post[] => * Retrieve the posts list from raw data. * * @param {EdgesResponse<RawArticle>[]} rawData - The raw data. - * @returns {Post[]} An array of posts. + * @returns {PostData[]} An array of posts. */ -export const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => { +export const getPostsList = ( + rawData: EdgesResponse<RawArticle>[] +): PostData[] => { const articlesList: RawArticle[] = []; rawData.forEach((articleData) => { articleData.edges.forEach((edge) => { diff --git a/src/utils/helpers/reading-time.test.ts b/src/utils/helpers/reading-time.test.ts new file mode 100644 index 0000000..24181a6 --- /dev/null +++ b/src/utils/helpers/reading-time.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from '@jest/globals'; +import { getReadingTimeFrom } from './reading-time'; + +describe('reading-time', () => { + it('can transform a words count into a reading time in minutes', () => { + const wordsCount = 250; + + // With the default settings, 250 words should be rounded to one minute. + expect(getReadingTimeFrom(wordsCount).inMinutes()).toBe(1); + }); + + it('can transform a words count into a reading time in minutes and seconds', () => { + const wordsCount = 1200; + const readingTime = getReadingTimeFrom(wordsCount).inMinutesAndSeconds(); + + expect(readingTime.minutes).toBeGreaterThan(1); + expect(readingTime.seconds).toBeGreaterThan(0); + }); + + it('can use a custom words per minute setting', () => { + const wordsCount = 100; + const wordsPerMinute = 100; + const readingTime = getReadingTimeFrom( + wordsCount, + wordsPerMinute + ).inMinutesAndSeconds(); + + expect(readingTime.minutes).toBe(1); + expect(readingTime.seconds).toBe(0); + }); +}); diff --git a/src/utils/helpers/reading-time.ts b/src/utils/helpers/reading-time.ts new file mode 100644 index 0000000..6cdeba4 --- /dev/null +++ b/src/utils/helpers/reading-time.ts @@ -0,0 +1,46 @@ +export type GetReadingTimeReturn = { + /** + * The reading time rounded to minutes. + */ + inMinutes: () => number; + /** + * The reading time in minutes and seconds. + */ + inMinutesAndSeconds: () => { + minutes: number; + seconds: number; + }; +}; + +/** + * Retrieve the reading time from a words count. + * + * @param {number} wordsCount - The number of words. + * @param {number} [wordsPerMinute] - How many words can we read per minute? + * @returns {GetReadingTimeReturn} Two methods to retrieve the reading time. + */ +export const getReadingTimeFrom = ( + wordsCount: number, + wordsPerMinute = 245 +): GetReadingTimeReturn => { + const ONE_MINUTE_IN_SECONDS = 60; + const wordsPerSecond = wordsPerMinute / ONE_MINUTE_IN_SECONDS; + const estimatedTimeInSeconds = wordsCount / wordsPerSecond; + + return { + inMinutes: () => Math.round(estimatedTimeInSeconds / ONE_MINUTE_IN_SECONDS), + inMinutesAndSeconds: () => { + const estimatedTimeInMinutes = Math.floor( + estimatedTimeInSeconds / ONE_MINUTE_IN_SECONDS + ); + + return { + minutes: estimatedTimeInMinutes, + seconds: Math.round( + estimatedTimeInSeconds - + estimatedTimeInMinutes * ONE_MINUTE_IN_SECONDS + ), + }; + }, + }; +}; |
