diff options
| -rw-r--r-- | src/components/molecules/layout/meta.tsx | 100 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.module.scss | 5 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.stories.tsx | 67 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.test.tsx | 60 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.tsx | 36 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.stories.tsx | 23 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.test.tsx | 16 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.tsx | 64 | ||||
| -rw-r--r-- | src/components/templates/page/page-layout.stories.tsx | 62 | ||||
| -rw-r--r-- | src/pages/blog/index.tsx | 169 | ||||
| -rw-r--r-- | src/styles/base/_typography.scss | 40 | ||||
| -rw-r--r-- | src/utils/hooks/use-reading-time.tsx | 58 |
12 files changed, 489 insertions, 211 deletions
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx index 1401ac4..1d5e04b 100644 --- a/src/components/molecules/layout/meta.tsx +++ b/src/components/molecules/layout/meta.tsx @@ -12,6 +12,17 @@ export type CustomMeta = { value: ReactNode | ReactNode[]; }; +export type MetaComments = { + /** + * 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`. @@ -35,7 +46,7 @@ export type MetaData = { /** * The comments count. */ - commentsCount?: string | JSX.Element; + comments?: MetaComments; /** * The creation date. */ @@ -86,6 +97,8 @@ export type MetaData = { update?: MetaDate; }; +export type MetaKey = keyof MetaData; + export type MetaProps = Omit< DescriptionListProps, 'items' | 'withSeparator' @@ -131,7 +144,7 @@ const Meta: FC<MetaProps> = ({ id: 'OI0N37', description: 'Meta: author label', }); - case 'commentsCount': + case 'comments': return intl.formatMessage({ defaultMessage: 'Comments:', id: 'jTVIh8', @@ -229,55 +242,69 @@ const Meta: FC<MetaProps> = ({ } 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}> - {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}`), - } - )} - </time> + <time dateTime={isoDateTime}>{dateString}</time> </Link> ) : ( - <time dateTime={isoDateTime}> - {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}`), - } - )} - </time> + <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) => { + const { count, target } = comments; + const commentsCount = intl.formatMessage( + { + defaultMessage: + '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}', + id: 'adTrj7', + description: 'Meta: comments count', + }, + { commentsCount: count } + ); + + return target ? <Link href={target}>{commentsCount}</Link> : commentsCount; + }; + + /** * 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>( + const getValue = <T extends MetaKey>( key: T, value: MetaData[T] ): string | ReactNode | ReactNode[] => { - if (key === 'creation' || key === 'publication' || key === 'update') { - return getDate(value as MetaDate); + switch (key) { + case 'comments': + return getCommentsCount(value as MetaComments); + case 'creation': + case 'publication': + case 'update': + return getDate(value as MetaDate); + default: + return value as string | ReactNode | ReactNode[]; } - return value as string | ReactNode | ReactNode[]; }; /** @@ -291,7 +318,7 @@ const Meta: FC<MetaProps> = ({ .map(([key, value]) => { if (!key || !value) return; - const metaKey = key as keyof MetaData; + const metaKey = key as MetaKey; return { id: metaKey, @@ -301,12 +328,9 @@ const Meta: FC<MetaProps> = ({ : getLabel(metaKey), layout: itemsLayout, value: - metaKey === 'custom' + metaKey === 'custom' && (value as CustomMeta) ? (value as CustomMeta).value - : getValue( - metaKey, - value as string | string[] | JSX.Element | JSX.Element[] - ), + : getValue(metaKey, value), } as DescriptionListItem; }) .filter((item): item is DescriptionListItem => !!item); diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss index f072082..8021b2b 100644 --- a/src/components/organisms/layout/posts-list.module.scss +++ b/src/components/organisms/layout/posts-list.module.scss @@ -37,3 +37,8 @@ } } } + +.btn { + display: flex; + margin: auto; +} diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx index d97ad03..de0478f 100644 --- a/src/components/organisms/layout/posts-list.stories.tsx +++ b/src/components/organisms/layout/posts-list.stories.tsx @@ -49,6 +49,16 @@ export default { required: false, }, }, + total: { + control: { + type: 'number', + }, + description: 'The number of posts.', + type: { + name: 'number', + required: true, + }, + }, }, } as ComponentMeta<typeof PostsList>; @@ -56,23 +66,25 @@ const Template: ComponentStory<typeof PostsList> = (args) => ( <PostsList {...args} /> ); +const excerpt1 = + '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.'; +const excerpt2 = + 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.'; +const excerpt3 = + 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.'; + const posts: Post[] = [ { - excerpt: - '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.', + excerpt: excerpt1, id: 'post-1', meta: { - publication: { date: '2022-02-26' }, - readingTime: '5 minutes', + dates: { publication: '2022-02-26' }, + readingTime: { wordsCount: excerpt1.split(' ').length }, thematics: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - <a key="cat-2" href="#"> - Cat 2 - </a>, + { id: 'cat-1', name: 'Cat 1', url: '#' }, + { id: 'cat-2', name: 'Cat 2', url: '#' }, ], - commentsCount: '1 comment', + commentsCount: 1, }, title: 'Ratione velit fuga', url: '#', @@ -86,35 +98,25 @@ const posts: Post[] = [ }, }, { - excerpt: - 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.', + excerpt: excerpt2, id: 'post-2', meta: { - publication: { date: '2022-02-20' }, - readingTime: '8 minutes', - thematics: [ - <a key="cat-2" href="#"> - Cat 2 - </a>, - ], - commentsCount: '0 comments', + dates: { publication: '2022-02-20' }, + readingTime: { wordsCount: excerpt2.split(' ').length }, + thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }], + commentsCount: 0, }, title: 'Debitis laudantium laudantium', url: '#', }, { - excerpt: - 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.', + excerpt: excerpt3, id: 'post-3', meta: { - publication: { date: '2021-12-20' }, - readingTime: '3 minutes', - thematics: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - ], - commentsCount: '3 comments', + dates: { publication: '2021-12-20' }, + readingTime: { wordsCount: excerpt3.split(' ').length }, + thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }], + commentsCount: 3, }, title: 'Quaerat ut corporis', url: '#', @@ -135,6 +137,7 @@ const posts: Post[] = [ export const Default = Template.bind({}); Default.args = { posts, + total: posts.length, }; /** @@ -144,6 +147,7 @@ export const ByYears = Template.bind({}); ByYears.args = { posts, byYear: true, + total: posts.length, }; ByYears.decorators = [ (Story) => ( @@ -159,4 +163,5 @@ ByYears.decorators = [ export const NoResults = Template.bind({}); NoResults.args = { posts: [], + total: posts.length, }; diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx index 98af1c3..9b226ac 100644 --- a/src/components/organisms/layout/posts-list.test.tsx +++ b/src/components/organisms/layout/posts-list.test.tsx @@ -1,23 +1,25 @@ import { render, screen } from '@test-utils'; import PostsList, { Post } from './posts-list'; +const excerpt1 = + '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.'; +const excerpt2 = + 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.'; +const excerpt3 = + 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.'; + const posts: Post[] = [ { - excerpt: - '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.', + excerpt: excerpt1, id: 'post-1', meta: { - publication: { date: '2022-02-26' }, - readingTime: '5 minutes', + dates: { publication: '2022-02-26' }, + readingTime: { wordsCount: excerpt1.split(' ').length }, thematics: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - <a key="cat-2" href="#"> - Cat 2 - </a>, + { id: 'cat-1', name: 'Cat 1', url: '#' }, + { id: 'cat-2', name: 'Cat 2', url: '#' }, ], - commentsCount: '1 comment', + commentsCount: 1, }, title: 'Ratione velit fuga', url: '#', @@ -26,38 +28,30 @@ const posts: Post[] = [ height: 480, src: 'http://placeimg.com/640/480', width: 640, + // @ts-ignore - Needed because of the placeholder image. + unoptimized: true, }, }, { - excerpt: - 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.', + excerpt: excerpt2, id: 'post-2', meta: { - publication: { date: '2022-02-20' }, - readingTime: '8 minutes', - thematics: [ - <a key="cat-2" href="#"> - Cat 2 - </a>, - ], - commentsCount: '0 comments', + dates: { publication: '2022-02-20' }, + readingTime: { wordsCount: excerpt2.split(' ').length }, + thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }], + commentsCount: 0, }, title: 'Debitis laudantium laudantium', url: '#', }, { - excerpt: - 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.', + excerpt: excerpt3, id: 'post-3', meta: { - publication: { date: '2021-12-20' }, - readingTime: '3 minutes', - thematics: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - ], - commentsCount: '3 comments', + dates: { publication: '2021-12-20' }, + readingTime: { wordsCount: excerpt3.split(' ').length }, + thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }], + commentsCount: 3, }, title: 'Quaerat ut corporis', url: '#', @@ -66,13 +60,15 @@ const posts: Post[] = [ height: 480, src: 'http://placeimg.com/640/480', width: 640, + // @ts-ignore - Needed because of the placeholder image. + unoptimized: true, }, }, ]; describe('PostsList', () => { it('renders the correct number of posts', () => { - render(<PostsList posts={posts} />); + render(<PostsList posts={posts} total={posts.length} />); expect(screen.getAllByRole('article')).toHaveLength(posts.length); }); }); diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx index 4855205..daf4491 100644 --- a/src/components/organisms/layout/posts-list.tsx +++ b/src/components/organisms/layout/posts-list.tsx @@ -3,6 +3,8 @@ import { FC } from 'react'; import { useIntl } from 'react-intl'; import Summary, { type SummaryProps } from './summary'; import styles from './posts-list.module.scss'; +import ProgressBar from '@components/atoms/loaders/progress-bar'; +import Button from '@components/atoms/buttons/button'; export type Post = SummaryProps & { /** @@ -28,6 +30,10 @@ export type PostsListProps = { * The posts heading level (hn). */ titleLevel?: HeadingLevel; + /** + * The total posts number. + */ + total: number; }; /** @@ -40,7 +46,7 @@ const sortPostsByYear = (data: Post[]): YearCollection => { const yearCollection: YearCollection = {}; data.forEach((post) => { - const postYear = new Date(post.meta.publication!.date) + const postYear = new Date(post.meta.dates.publication) .getFullYear() .toString(); yearCollection[postYear] = [...(yearCollection[postYear] || []), post]; @@ -58,6 +64,7 @@ const PostsList: FC<PostsListProps> = ({ byYear = false, posts, titleLevel, + total, }) => { const intl = useIntl(); @@ -106,6 +113,22 @@ const PostsList: FC<PostsListProps> = ({ }); }; + const progressInfo = intl.formatMessage( + { + defaultMessage: + '{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}', + description: 'PostsList: loaded articles progress', + id: '9MeLN3', + }, + { articlesCount: posts.length, total: total } + ); + + const loadMore = intl.formatMessage({ + defaultMessage: 'Load more articles?', + id: 'uaqd5F', + description: 'PostsList: load more button', + }); + return posts.length === 0 ? ( <p> {intl.formatMessage({ @@ -115,7 +138,16 @@ const PostsList: FC<PostsListProps> = ({ })} </p> ) : ( - <>{getPosts()}</> + <> + {getPosts()} + <ProgressBar + min={1} + max={total} + current={posts.length} + info={progressInfo} + /> + <Button className={styles.btn}>{loadMore}</Button> + </> ); }; diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx index 42f1d44..92736b8 100644 --- a/src/components/organisms/layout/summary.stories.tsx +++ b/src/components/organisms/layout/summary.stories.tsx @@ -91,18 +91,17 @@ const cover = { unoptimized: true, }; +const excerpt = + '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.'; + const meta = { - publication: { date: '2022-04-11' }, - readingTime: '5 minutes', + dates: { publication: '2022-04-11' }, + readingTime: { wordsCount: excerpt.split(' ').length }, thematics: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - <a key="cat-2" href="#"> - Cat 2 - </a>, + { id: 'cat-1', name: 'Cat 1', url: '#' }, + { id: 'cat-2', name: 'Cat 2', url: '#' }, ], - commentsCount: '1 comment', + commentsCount: 1, }; /** @@ -110,8 +109,7 @@ const meta = { */ export const Default = Template.bind({}); Default.args = { - excerpt: - '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.', + excerpt, meta, title: 'Odio odit necessitatibus', url: '#', @@ -123,8 +121,7 @@ Default.args = { export const WithCover = Template.bind({}); WithCover.args = { cover, - excerpt: - '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.', + excerpt, meta, title: 'Odio odit necessitatibus', url: '#', diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx index 09b797c..9e34254 100644 --- a/src/components/organisms/layout/summary.test.tsx +++ b/src/components/organisms/layout/summary.test.tsx @@ -12,17 +12,13 @@ const excerpt = '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.'; const meta = { - publication: { date: '2022-04-11' }, - readingTime: '5 minutes', + dates: { publication: '2022-04-11' }, + readingTime: { wordsCount: excerpt.split(' ').length }, thematics: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - <a key="cat-2" href="#"> - Cat 2 - </a>, + { id: 'cat-1', name: 'Cat 1', url: '#' }, + { id: 'cat-2', name: 'Cat 2', url: '#' }, ], - commentsCount: '1 comment', + commentsCount: 1, }; const title = 'Odio odit necessitatibus'; @@ -77,6 +73,6 @@ describe('Summary', () => { it('renders some meta', () => { render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />); - expect(screen.getByText(meta.readingTime)).toBeInTheDocument(); + expect(screen.getByText(meta.thematics[0].name)).toBeInTheDocument(); }); }); diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx index 8b47833..1c4a38b 100644 --- a/src/components/organisms/layout/summary.tsx +++ b/src/components/organisms/layout/summary.tsx @@ -5,7 +5,9 @@ import Link from '@components/atoms/links/link'; import ResponsiveImage, { type ResponsiveImageProps, } from '@components/molecules/images/responsive-image'; -import Meta, { MetaData } from '@components/molecules/layout/meta'; +import Meta, { type MetaData } from '@components/molecules/layout/meta'; +import { type Dates } from '@ts/types/app'; +import useReadingTime from '@utils/hooks/use-reading-time'; import { FC, ReactNode } from 'react'; import { useIntl } from 'react-intl'; import styles from './summary.module.scss'; @@ -15,15 +17,25 @@ export type Cover = Pick< 'alt' | 'src' | 'width' | 'height' >; -export type SummaryMeta = Pick< - MetaData, - | 'author' - | 'commentsCount' - | 'publication' - | 'readingTime' - | 'thematics' - | 'update' ->; +export type SummaryMetaLink = { + id: number | string; + name: string; + url: string; +}; + +export type SummaryMetaReadingTime = { + wordsCount: number; + onlyMinutes?: boolean; +}; + +export type SummaryMeta = { + author?: string; + commentsCount?: number; + dates: Dates; + readingTime: SummaryMetaReadingTime; + thematics?: SummaryMetaLink[]; + topics?: SummaryMetaLink[]; +}; export type SummaryProps = { /** @@ -79,6 +91,36 @@ const Summary: FC<SummaryProps> = ({ ), } ); + const { wordsCount, onlyMinutes } = meta.readingTime; + const readingTime = useReadingTime(wordsCount, onlyMinutes); + + const getMeta = (data: SummaryMeta): MetaData => { + const { author, commentsCount, dates, thematics, topics } = data; + + return { + author, + 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: { + count: commentsCount || 0, + target: `${url}#comments`, + }, + }; + }; return ( <article className={styles.wrapper}> @@ -104,7 +146,7 @@ const Summary: FC<SummaryProps> = ({ </ButtonLink> </div> <footer className={styles.footer}> - <Meta data={meta} layout="column" className={styles.meta} /> + <Meta data={getMeta(meta)} layout="column" className={styles.meta} /> </footer> </article> ); diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx index b4b0d68..480c76e 100644 --- a/src/components/templates/page/page-layout.stories.tsx +++ b/src/components/templates/page/page-layout.stories.tsx @@ -379,23 +379,25 @@ const postsListBreadcrumb = [ { id: 'blog', url: '#', name: 'Blog' }, ]; +const excerpt1 = + '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.'; +const excerpt2 = + 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.'; +const excerpt3 = + 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.'; + const posts = [ { - excerpt: - '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.', + excerpt: excerpt1, id: 'post-1', meta: { - publication: { date: '2022-02-26' }, - readingTime: '5 minutes', - categories: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - <a key="cat-2" href="#"> - Cat 2 - </a>, + dates: { publication: '2022-02-26' }, + readingTime: { wordsCount: excerpt1.split(' ').length }, + thematics: [ + { id: 'cat-1', name: 'Cat 1', url: '#' }, + { id: 'cat-2', name: 'Cat 2', url: '#' }, ], - commentsCount: '1 comment', + commentsCount: 1, }, title: 'Ratione velit fuga', url: '#', @@ -409,39 +411,25 @@ const posts = [ }, }, { - excerpt: - 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.', + excerpt: excerpt2, id: 'post-2', meta: { - publication: { - date: '2022-02-20', - }, - readingTime: '8 minutes', - categories: [ - <a key="cat-2" href="#"> - Cat 2 - </a>, - ], - comments: '0 comments', + dates: { publication: '2022-02-20' }, + readingTime: { wordsCount: excerpt2.split(' ').length }, + thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }], + commentsCount: 0, }, title: 'Debitis laudantium laudantium', url: '#', }, { - excerpt: - 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.', + excerpt: excerpt3, id: 'post-3', meta: { - publication: { - date: '2021-12-20', - }, - readingTime: '3 minutes', - categories: [ - <a key="cat-1" href="#"> - Cat 1 - </a>, - ], - comments: '3 comments', + dates: { publication: '2021-12-20' }, + readingTime: { wordsCount: excerpt3.split(' ').length }, + thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }], + commentsCount: 3, }, title: 'Quaerat ut corporis', url: '#', @@ -476,7 +464,7 @@ Blog.args = { headerMeta: { total: `${posts.length} posts` }, children: ( <> - <PostsList posts={posts} byYear={true} /> + <PostsList posts={posts} byYear={true} total={posts.length} /> <ProgressBar min={1} max={1} current={1} info="1/1 page loaded." /> </> ), diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx new file mode 100644 index 0000000..dc72388 --- /dev/null +++ b/src/pages/blog/index.tsx @@ -0,0 +1,169 @@ +import ProgressBar from '@components/atoms/loaders/progress-bar'; +import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb'; +import PostsList, { Post } from '@components/organisms/layout/posts-list'; +import PageLayout from '@components/templates/page/page-layout'; +import { getArticles, getTotalArticles } from '@services/graphql/articles'; +import { Article, Meta } from '@ts/types/app'; +import { settings } from '@utils/config'; +import { loadTranslation, Messages } from '@utils/helpers/i18n'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticProps, NextPage } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; +import { useIntl } from 'react-intl'; +import { Blog, Graph, WebPage } from 'schema-dts'; + +type BlogPageProps = { + posts: Article[]; + totalPosts: number; + translation: Messages; +}; + +/** + * Blog index page. + */ +const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => { + const intl = useIntl(); + const title = intl.formatMessage({ + defaultMessage: 'Blog', + description: 'BlogPage: page title', + id: '7TbbIk', + }); + const homeLabel = intl.formatMessage({ + defaultMessage: 'Home', + description: 'Breadcrumb: home label', + id: 'j5k9Fe', + }); + const breadcrumb: BreadcrumbItem[] = [ + { id: 'home', name: homeLabel, url: '/' }, + { id: 'blog', name: title, url: '/blog' }, + ]; + + const { website } = useSettings(); + const { asPath } = useRouter(); + const pageTitle = intl.formatMessage( + { + defaultMessage: 'Blog: development, open source - {websiteName}', + description: 'BlogPage: SEO - Page title', + id: '+Y+tLK', + }, + { websiteName: website.name } + ); + const pageDescription = intl.formatMessage( + { + defaultMessage: + "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.", + description: 'BlogPage: SEO - Meta description', + id: '18h/t0', + }, + { websiteName: website.name } + ); + const pageUrl = `${website.url}${asPath}`; + + const webpageSchema: WebPage = { + '@id': `${pageUrl}`, + '@type': 'WebPage', + breadcrumb: { '@id': `${website.url}/#breadcrumb` }, + name: pageTitle, + description: pageDescription, + inLanguage: website.locales.default, + reviewedBy: { '@id': `${website.url}/#branding` }, + url: `${website.url}`, + isPartOf: { + '@id': `${website.url}`, + }, + }; + + const blogSchema: Blog = { + '@id': `${website.url}/#blog`, + '@type': 'Blog', + author: { '@id': `${website.url}/#branding` }, + creator: { '@id': `${website.url}/#branding` }, + editor: { '@id': `${website.url}/#branding` }, + inLanguage: website.locales.default, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: { '@id': `${pageUrl}` }, + }; + + const schemaJsonLd: Graph = { + '@context': 'https://schema.org', + '@graph': [webpageSchema, blogSchema], + }; + + const postsCount = intl.formatMessage( + { + defaultMessage: + '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', + id: 'OF5cPz', + description: 'BlogPage: posts count meta', + }, + { postsCount: totalPosts } + ); + + const getPostMeta = (data: Meta<'article'>): Post['meta'] => { + const { commentsCount, dates, thematics, wordsCount } = data; + + return { + commentsCount, + dates, + readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true }, + thematics: thematics?.map((thematic) => { + return { ...thematic, url: `/thematique/${thematic.slug}` }; + }), + }; + }; + + const getPosts = (): Post[] => { + return posts.map((post) => { + return { + ...post, + cover: post.meta.cover, + excerpt: post.intro, + meta: getPostMeta(post.meta), + url: `/article/${post.slug}`, + }; + }); + }; + + return ( + <> + <Head> + <title>{pageTitle}</title> + <meta name="description" content={pageDescription} /> + <meta property="og:url" content={`${pageUrl}`} /> + <meta property="og:type" content="website" /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={pageDescription} /> + </Head> + <Script + id="schema-blog" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <PageLayout + title={title} + breadcrumb={breadcrumb} + headerMeta={{ total: postsCount }} + > + <PostsList posts={getPosts()} byYear={true} total={totalPosts} /> + </PageLayout> + </> + ); +}; + +export const getStaticProps: GetStaticProps = async ({ locale }) => { + const posts = await getArticles({ first: settings.postsPerPage }); + const totalPosts = await getTotalArticles(); + const translation = await loadTranslation(locale); + + return { + props: { + posts: JSON.parse(JSON.stringify(posts.articles)), + totalPosts, + translation, + }, + }; +}; + +export default BlogPage; diff --git a/src/styles/base/_typography.scss b/src/styles/base/_typography.scss index f1cb38a..7b7a695 100644 --- a/src/styles/base/_typography.scss +++ b/src/styles/base/_typography.scss @@ -1,5 +1,4 @@ @use "@styles/abstracts/functions" as fun; -@use "@styles/abstracts/variables" as var; h1 { font-size: var(--font-size-3xl); @@ -116,7 +115,7 @@ dl { ul, ol, dl { - margin: var(--spacing-md) 0; + margin: var(--spacing-sm) 0; & & { margin: var(--spacing-2xs) 0 0; @@ -137,13 +136,13 @@ a { background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%) 0 0 / 100% 200% no-repeat; color: var(--color-primary); - text-decoration-thickness: 13%; + text-decoration-thickness: 0.15em; text-underline-offset: 20%; transition: all 0.3s linear 0s, text-decoration 0.18s ease-in-out 0s; &:hover { color: var(--color-primary-light); - text-decoration-thickness: 23%; + text-decoration-thickness: 0.25em; } &:focus { @@ -156,39 +155,6 @@ a { color: var(--color-primary-dark); text-decoration-thickness: 18%; } - - &.external { - &::after { - display: inline-block; - content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>')); - } - - &:focus:not(:active)::after { - content: "\0000a0"url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>')); - } - } - - &[hreflang] { - &::after { - display: inline-block; - content: "\0000a0["attr(hreflang) "]"; - font-size: var(--font-size-sm); - } - - &.external { - &::after { - content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg( - '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>' - )); - } - - &:focus:not(:active)::after { - content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg( - '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>' - )); - } - } - } } button, diff --git a/src/utils/hooks/use-reading-time.tsx b/src/utils/hooks/use-reading-time.tsx new file mode 100644 index 0000000..fb54135 --- /dev/null +++ b/src/utils/hooks/use-reading-time.tsx @@ -0,0 +1,58 @@ +import { useIntl } from 'react-intl'; + +/** + * Retrieve the estimated reading time by words count. + * + * @param {number} wordsCount - The number of words. + * @returns {string} The estimated reading time. + */ +const useReadingTime = ( + wordsCount: number, + onlyMinutes: boolean = false +): string => { + const intl = useIntl(); + const wordsPerMinute = 245; + const wordsPerSecond = wordsPerMinute / 60; + const estimatedTimeInSeconds = wordsCount / wordsPerSecond; + + if (onlyMinutes) { + const estimatedTimeInMinutes = Math.round(estimatedTimeInSeconds / 60); + + return intl.formatMessage( + { + defaultMessage: '{minutesCount} minutes', + description: 'useReadingTime: rounded minutes count', + id: 's1i43J', + }, + { minutesCount: estimatedTimeInMinutes } + ); + } else { + const estimatedTimeInMinutes = Math.floor(estimatedTimeInSeconds / 60); + + if (estimatedTimeInMinutes <= 0) { + return intl.formatMessage( + { + defaultMessage: '{count} seconds', + description: 'useReadingTime: seconds count', + id: 'i7Wq3G', + }, + { count: estimatedTimeInSeconds.toFixed(0) } + ); + } + + const remainingSeconds = Math.round( + estimatedTimeInSeconds - estimatedTimeInMinutes * 60 + ).toFixed(0); + + return intl.formatMessage( + { + defaultMessage: '{minutesCount} minutes {secondsCount} seconds', + description: 'useReadingTime: minutes + seconds count', + id: 'OevMeU', + }, + { minutesCount: estimatedTimeInMinutes, secondsCount: remainingSeconds } + ); + } +}; + +export default useReadingTime; |
