summaryrefslogtreecommitdiffstats
path: root/src/components/organisms/layout
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-24 19:35:12 +0200
committerGitHub <noreply@github.com>2022-05-24 19:35:12 +0200
commitc85ab5ad43ccf52881ee224672c41ec30021cf48 (patch)
tree8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/organisms/layout
parent52404177c07a2aab7fc894362fb3060dff2431a0 (diff)
parent11b9de44a4b2f305a6a484187805e429b2767118 (diff)
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/organisms/layout')
-rw-r--r--src/components/organisms/layout/cards-list.module.scss32
-rw-r--r--src/components/organisms/layout/cards-list.stories.tsx136
-rw-r--r--src/components/organisms/layout/cards-list.test.tsx55
-rw-r--r--src/components/organisms/layout/cards-list.tsx77
-rw-r--r--src/components/organisms/layout/comment.fixture.tsx41
-rw-r--r--src/components/organisms/layout/comment.module.scss91
-rw-r--r--src/components/organisms/layout/comment.stories.tsx128
-rw-r--r--src/components/organisms/layout/comment.test.tsx47
-rw-r--r--src/components/organisms/layout/comment.tsx171
-rw-r--r--src/components/organisms/layout/comments-list.fixture.tsx106
-rw-r--r--src/components/organisms/layout/comments-list.module.scss16
-rw-r--r--src/components/organisms/layout/comments-list.stories.tsx91
-rw-r--r--src/components/organisms/layout/comments-list.test.tsx12
-rw-r--r--src/components/organisms/layout/comments-list.tsx60
-rw-r--r--src/components/organisms/layout/footer.module.scss41
-rw-r--r--src/components/organisms/layout/footer.stories.tsx90
-rw-r--r--src/components/organisms/layout/footer.test.tsx33
-rw-r--r--src/components/organisms/layout/footer.tsx77
-rw-r--r--src/components/organisms/layout/header.module.scss50
-rw-r--r--src/components/organisms/layout/header.stories.tsx153
-rw-r--r--src/components/organisms/layout/header.test.tsx46
-rw-r--r--src/components/organisms/layout/header.tsx48
-rw-r--r--src/components/organisms/layout/no-results.stories.tsx28
-rw-r--r--src/components/organisms/layout/no-results.test.tsx14
-rw-r--r--src/components/organisms/layout/no-results.tsx38
-rw-r--r--src/components/organisms/layout/overview.module.scss44
-rw-r--r--src/components/organisms/layout/overview.stories.tsx77
-rw-r--r--src/components/organisms/layout/overview.test.tsx26
-rw-r--r--src/components/organisms/layout/overview.tsx61
-rw-r--r--src/components/organisms/layout/posts-list.fixture.tsx63
-rw-r--r--src/components/organisms/layout/posts-list.module.scss62
-rw-r--r--src/components/organisms/layout/posts-list.stories.tsx194
-rw-r--r--src/components/organisms/layout/posts-list.test.tsx46
-rw-r--r--src/components/organisms/layout/posts-list.tsx239
-rw-r--r--src/components/organisms/layout/summary.fixture.tsx25
-rw-r--r--src/components/organisms/layout/summary.module.scss121
-rw-r--r--src/components/organisms/layout/summary.stories.tsx107
-rw-r--r--src/components/organisms/layout/summary.test.tsx54
-rw-r--r--src/components/organisms/layout/summary.tsx136
39 files changed, 2936 insertions, 0 deletions
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
new file mode 100644
index 0000000..6274b93
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -0,0 +1,32 @@
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width, 30ch))
+ );
+ gap: var(--spacing-sm);
+ place-content: center;
+ align-items: stretch;
+ justify-items: stretch;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ gap: var(--spacing-lg);
+ }
+ }
+
+ &--ordered {
+ @extend %reset-ordered-list;
+ }
+
+ &--unordered {
+ @extend %reset-list;
+ }
+}
+
+.card {
+ height: 100%;
+}
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
new file mode 100644
index 0000000..c19220a
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -0,0 +1,136 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CardsListComponent, { type CardsListItem } from './cards-list';
+
+/**
+ * CardsList - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Layout',
+ component: CardsListComponent,
+ args: {
+ coverFit: 'cover',
+ kind: 'unordered',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ coverFit: {
+ control: {
+ type: 'select',
+ },
+ description: 'The cover fit.',
+ options: ['fill', 'contain', 'cover', 'none', 'scale-down'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'cover' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ description: 'The cards data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'The heading level for each card.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CardsListComponent>;
+
+const Template: ComponentStory<typeof CardsListComponent> = (args) => (
+ <CardsListComponent {...args} />
+);
+
+const items: CardsListItem[] = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: { thematics: ['Velit', 'Ex', 'Alias'] },
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: { thematics: ['Voluptas'] },
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: {
+ thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+/**
+ * Layout Stories - Cards list
+ */
+export const CardsList = Template.bind({});
+CardsList.args = {
+ items,
+ titleLevel: 2,
+};
diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx
new file mode 100644
index 0000000..8558fa6
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@test-utils';
+import CardsList, { type CardsListItem } from './cards-list';
+
+const items: CardsListItem[] = [
+ {
+ id: 'card-1',
+ cover: {
+ alt: 'card 1 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: { thematics: ['Velit', 'Ex', 'Alias'] },
+ tagline: 'Molestias ut error',
+ title: 'Et alias omnis',
+ url: '#',
+ },
+ {
+ id: 'card-2',
+ cover: {
+ alt: 'card 2 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: { thematics: ['Voluptas'] },
+ tagline: 'Quod vel accusamus',
+ title: 'Laboriosam doloremque mollitia',
+ url: '#',
+ },
+ {
+ id: 'card-3',
+ cover: {
+ alt: 'card 3 picture',
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ height: 480,
+ },
+ meta: {
+ thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'],
+ },
+ tagline: 'Quo error eum',
+ title: 'Magni rem nulla',
+ url: '#',
+ },
+];
+
+describe('CardsList', () => {
+ it('renders a list of cards', () => {
+ render(<CardsList items={items} titleLevel={2} />);
+ expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(
+ items.length
+ );
+ });
+});
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
new file mode 100644
index 0000000..1feddd0
--- /dev/null
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -0,0 +1,77 @@
+import List, {
+ type ListItem,
+ type ListProps,
+} from '@components/atoms/lists/list';
+import Card, { type CardProps } from '@components/molecules/layout/card';
+import { FC } from 'react';
+import styles from './cards-list.module.scss';
+
+export type CardsListItem = Omit<
+ CardProps,
+ 'className' | 'coverFit' | 'titleLevel'
+> & {
+ /**
+ * The card id.
+ */
+ id: string;
+};
+
+export type CardsListProps = Pick<CardProps, 'coverFit' | 'titleLevel'> &
+ Pick<ListProps, 'kind'> & {
+ /**
+ * Set additional classnames to the list wrapper.
+ */
+ className?: string;
+ /**
+ * The cards data.
+ */
+ items: CardsListItem[];
+ };
+
+/**
+ * CardsList component
+ *
+ * Return a list of Card components.
+ */
+const CardsList: FC<CardsListProps> = ({
+ className = '',
+ coverFit,
+ items,
+ kind = 'unordered',
+ titleLevel,
+}) => {
+ const kindModifier = `wrapper--${kind}`;
+
+ /**
+ * Format the cards data to be used by the List component.
+ *
+ * @param {CardsListItem[]} cards - An array of card data.
+ * @returns {ListItem[]} The formatted cards data.
+ */
+ const getCards = (cards: CardsListItem[]): ListItem[] => {
+ return cards.map(({ id, ...card }) => {
+ return {
+ id,
+ value: (
+ <Card
+ key={id}
+ coverFit={coverFit}
+ titleLevel={titleLevel}
+ className={styles.card}
+ {...card}
+ />
+ ),
+ };
+ });
+ };
+
+ return (
+ <List
+ kind="flex"
+ items={getCards(items)}
+ className={`${styles.wrapper} ${styles[kindModifier]} ${className}`}
+ />
+ );
+};
+
+export default CardsList;
diff --git a/src/components/organisms/layout/comment.fixture.tsx b/src/components/organisms/layout/comment.fixture.tsx
new file mode 100644
index 0000000..0118139
--- /dev/null
+++ b/src/components/organisms/layout/comment.fixture.tsx
@@ -0,0 +1,41 @@
+import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
+import { CommentProps } from './comment';
+
+export const author = {
+ avatar: {
+ alt: 'Author avatar',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ name: 'Armand',
+ website: 'https://www.armandphilippot.com/',
+};
+
+export const content =
+ 'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
+
+export const date = '2021-04-03 23:04:24';
+
+export const meta = {
+ author,
+ date,
+};
+
+export const id = 5;
+
+export const saveComment = async () => {
+ /** Do nothing. */
+};
+
+export const data: CommentProps = {
+ approved: true,
+ content,
+ id,
+ meta,
+ parentId: 0,
+ saveComment,
+};
+
+export const formattedDate = getFormattedDate(date);
+export const formattedTime = getFormattedTime(date);
diff --git a/src/components/organisms/layout/comment.module.scss b/src/components/organisms/layout/comment.module.scss
new file mode 100644
index 0000000..d2b68e1
--- /dev/null
+++ b/src/components/organisms/layout/comment.module.scss
@@ -0,0 +1,91 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ padding: var(--spacing-md);
+ background: var(--color-bg);
+ border: fun.convert-px(1) solid var(--color-border);
+ box-shadow: fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow-light),
+ fun.convert-px(4) fun.convert-px(4) fun.convert-px(3) fun.convert-px(-2)
+ var(--color-shadow);
+
+ &--comment {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ display: grid;
+ grid-template-columns: minmax(0, #{fun.convert-px(150)}) minmax(0, 1fr);
+ column-gap: var(--spacing-lg);
+ }
+ }
+ }
+
+ &--form {
+ display: flex;
+ flex-flow: column wrap;
+ place-content: center;
+ margin-top: var(--spacing-sm);
+ }
+
+ .header {
+ display: flex;
+ flex-flow: column wrap;
+ align-items: center;
+ row-gap: var(--spacing-sm);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-row: 1 / 4;
+ }
+ }
+ }
+
+ .author {
+ color: var(--color-primary-darker);
+ font-weight: 600;
+ text-align: center;
+ }
+
+ .avatar {
+ width: fun.convert-px(85);
+ height: fun.convert-px(85);
+ position: relative;
+ border-radius: fun.convert-px(3);
+ box-shadow: 0 0 0 fun.convert-px(1) var(--color-shadow-light),
+ fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(1)
+ var(--color-shadow);
+
+ img {
+ border-radius: fun.convert-px(3);
+ }
+ }
+
+ .date {
+ margin: var(--spacing-sm) 0;
+ font-size: var(--font-size-sm);
+
+ &__item {
+ justify-content: center;
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ margin: 0 0 var(--spacing-sm);
+
+ &__item {
+ justify-content: left;
+ }
+ }
+ }
+ }
+
+ .body {
+ overflow-wrap: break-word;
+ }
+
+ .footer {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ padding: var(--spacing-md) 0 0;
+ }
+}
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
new file mode 100644
index 0000000..7a8ac95
--- /dev/null
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -0,0 +1,128 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CommentComponent from './comment';
+import { data } from './comment.fixture';
+
+const saveComment = async () => {
+ /** Do nothing. */
+};
+
+/**
+ * Comment - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Layout/Comment',
+ component: CommentComponent,
+ args: {
+ canReply: true,
+ saveComment,
+ },
+ argTypes: {
+ author: {
+ description: 'The author data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ canReply: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Enable or disable the reply button.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ content: {
+ control: {
+ type: 'text',
+ },
+ description: 'The comment body.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ id: {
+ control: {
+ type: 'number',
+ },
+ description: 'The comment id.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ parentId: {
+ control: {
+ type: null,
+ },
+ description: 'The parent id if it is a reply.',
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ publication: {
+ description: 'The publication date.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ saveComment: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to save the comment form data.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CommentComponent>;
+
+const Template: ComponentStory<typeof CommentComponent> = (args) => (
+ <CommentComponent {...args} />
+);
+
+/**
+ * Layout Stories - Approved
+ */
+export const Approved = Template.bind({});
+Approved.args = {
+ ...data,
+};
+
+/**
+ * Layout Stories - Unapproved
+ */
+export const Unapproved = Template.bind({});
+Unapproved.args = {
+ ...data,
+ approved: false,
+};
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
new file mode 100644
index 0000000..66003d1
--- /dev/null
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -0,0 +1,47 @@
+import { render, screen } from '@test-utils';
+import Comment from './comment';
+import {
+ author,
+ data,
+ formattedDate,
+ formattedTime,
+ id,
+} from './comment.fixture';
+
+describe('Comment', () => {
+ it('renders an avatar', () => {
+ render(<Comment canReply={true} {...data} />);
+ expect(
+ screen.getByRole('img', { name: author.avatar.alt })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the author website url', () => {
+ render(<Comment canReply={true} {...data} />);
+ expect(screen.getByRole('link', { name: author.name })).toHaveAttribute(
+ 'href',
+ author.website
+ );
+ });
+
+ it('renders a permalink to the comment', () => {
+ render(<Comment canReply={true} {...data} />);
+ expect(
+ screen.getByRole('link', {
+ name: `${formattedDate} at ${formattedTime}`,
+ })
+ ).toHaveAttribute('href', `/#comment-${id}`);
+ });
+
+ it('renders a reply button', () => {
+ render(<Comment canReply={true} {...data} />);
+ expect(screen.getByRole('button', { name: 'Reply' })).toBeInTheDocument();
+ });
+
+ it('does not render a reply button', () => {
+ render(<Comment canReply={false} {...data} />);
+ expect(
+ screen.queryByRole('button', { name: 'Reply' })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
new file mode 100644
index 0000000..f62f95c
--- /dev/null
+++ b/src/components/organisms/layout/comment.tsx
@@ -0,0 +1,171 @@
+import Button from '@components/atoms/buttons/button';
+import Link from '@components/atoms/links/link';
+import Meta from '@components/molecules/layout/meta';
+import { type Comment as CommentType } from '@ts/types/app';
+import useSettings from '@utils/hooks/use-settings';
+import Image from 'next/image';
+import Script from 'next/script';
+import { FC, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { type Comment as CommentSchema, type WithContext } from 'schema-dts';
+import CommentForm, { type CommentFormProps } from '../forms/comment-form';
+import styles from './comment.module.scss';
+
+export type CommentProps = Pick<
+ CommentType,
+ 'approved' | 'content' | 'id' | 'meta' | 'parentId'
+> &
+ Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
+ /**
+ * Enable or disable the reply button. Default: true.
+ */
+ canReply?: boolean;
+ };
+
+/**
+ * Comment component
+ *
+ * Render a single comment.
+ */
+const Comment: FC<CommentProps> = ({
+ approved,
+ canReply = true,
+ content,
+ id,
+ meta,
+ Notice,
+ parentId,
+ saveComment,
+ ...props
+}) => {
+ const intl = useIntl();
+ const { website } = useSettings();
+ const [isReplying, setIsReplying] = useState<boolean>(false);
+
+ if (!approved) {
+ return (
+ <div className={styles.wrapper}>
+ {intl.formatMessage({
+ defaultMessage: 'This comment is awaiting moderation...',
+ description: 'Comment: awaiting moderation',
+ id: '6a1Uo6',
+ })}
+ </div>
+ );
+ }
+
+ const { author, date } = meta;
+ const [publicationDate, publicationTime] = date.split(' ');
+
+ const buttonLabel = isReplying
+ ? intl.formatMessage({
+ defaultMessage: 'Cancel reply',
+ description: 'Comment: cancel reply button',
+ id: 'LCorTC',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Reply',
+ description: 'Comment: reply button',
+ id: 'hzHuCc',
+ });
+ const formTitle = intl.formatMessage({
+ defaultMessage: 'Leave a reply',
+ description: 'Comment: comment form title',
+ id: '2fD5CI',
+ });
+
+ const commentSchema: WithContext<CommentSchema> = {
+ '@context': 'https://schema.org',
+ '@id': `${website.url}/#comment-${id}`,
+ '@type': 'Comment',
+ parentItem: parentId
+ ? { '@id': `${website.url}/#comment-${parentId}` }
+ : undefined,
+ about: { '@type': 'Article', '@id': `${website.url}/#article` },
+ author: {
+ '@type': 'Person',
+ name: author.name,
+ image: author.avatar?.src,
+ url: author.website,
+ },
+ creator: {
+ '@type': 'Person',
+ name: author.name,
+ image: author.avatar?.src,
+ url: author.website,
+ },
+ dateCreated: date,
+ datePublished: date,
+ text: content,
+ };
+
+ return (
+ <>
+ <Script
+ id="schema-comments"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(commentSchema) }}
+ />
+ <article
+ id={`comment-${id}`}
+ className={`${styles.wrapper} ${styles['wrapper--comment']}`}
+ >
+ <header className={styles.header}>
+ {author.avatar && (
+ <div className={styles.avatar}>
+ <Image
+ src={author.avatar.src}
+ alt={author.avatar.alt}
+ layout="fill"
+ objectFit="cover"
+ {...props}
+ />
+ </div>
+ )}
+ {author.website ? (
+ <Link href={author.website} className={styles.author}>
+ {author.name}
+ </Link>
+ ) : (
+ <span className={styles.author}>{author.name}</span>
+ )}
+ </header>
+ <Meta
+ data={{
+ publication: {
+ date: publicationDate,
+ time: publicationTime,
+ target: `#comment-${id}`,
+ },
+ }}
+ layout="inline"
+ itemsLayout="inline"
+ className={styles.date}
+ groupClassName={styles.date__item}
+ />
+ <div
+ className={styles.body}
+ dangerouslySetInnerHTML={{ __html: content }}
+ />
+ <footer className={styles.footer}>
+ {canReply && (
+ <Button kind="tertiary" onClick={() => setIsReplying(!isReplying)}>
+ {buttonLabel}
+ </Button>
+ )}
+ </footer>
+ </article>
+ {isReplying && (
+ <CommentForm
+ Notice={Notice}
+ parentId={id}
+ saveComment={saveComment}
+ title={formTitle}
+ className={`${styles.wrapper} ${styles['wrapper--form']}`}
+ />
+ )}
+ </>
+ );
+};
+
+export default Comment;
diff --git a/src/components/organisms/layout/comments-list.fixture.tsx b/src/components/organisms/layout/comments-list.fixture.tsx
new file mode 100644
index 0000000..2618f77
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.fixture.tsx
@@ -0,0 +1,106 @@
+import { Comment } from '@ts/types/app';
+
+export const comments: Comment[] = [
+ {
+ approved: true,
+ content:
+ 'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
+ id: 1,
+ meta: {
+ author: {
+ avatar: {
+ alt: 'Author 1 avatar',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ name: 'Author 1',
+ },
+ date: '2021-04-03 18:04:11',
+ },
+ parentId: 0,
+ replies: [],
+ },
+ {
+ approved: true,
+ content:
+ 'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
+ id: 2,
+ meta: {
+ author: {
+ avatar: {
+ alt: 'Author 2 avatar',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ name: 'Author 2',
+ website: '#',
+ },
+ date: '2021-04-03 23:30:20',
+ },
+ parentId: 0,
+ replies: [
+ {
+ approved: true,
+ content:
+ 'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
+ id: 4,
+ meta: {
+ author: {
+ avatar: {
+ alt: 'Author 4 avatar',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ name: 'Author 4',
+ },
+ date: '2021-04-03 23:04:24',
+ },
+ parentId: 2,
+ replies: [],
+ },
+ {
+ approved: true,
+ content:
+ 'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
+ id: 5,
+ meta: {
+ author: {
+ avatar: {
+ alt: 'Author 1 avatar',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ name: 'Author 1',
+ },
+ date: '2021-04-04 08:05:14',
+ },
+ parentId: 2,
+ replies: [],
+ },
+ ],
+ },
+ {
+ approved: false,
+ content:
+ 'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
+ id: 3,
+ meta: {
+ author: {
+ avatar: {
+ alt: 'Author 3',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+ },
+ name: 'Author 3',
+ },
+ date: '2021-09-13 13:24:54',
+ },
+ parentId: 0,
+ replies: [],
+ },
+];
diff --git a/src/components/organisms/layout/comments-list.module.scss b/src/components/organisms/layout/comments-list.module.scss
new file mode 100644
index 0000000..803a418
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.module.scss
@@ -0,0 +1,16 @@
+@use "@styles/abstracts/placeholders";
+
+.list {
+ @extend %reset-ordered-list;
+
+ & & {
+ margin: var(--spacing-sm) 0;
+ padding-left: var(--spacing-sm);
+ }
+}
+
+.item {
+ &:not(:last-child) {
+ margin-bottom: var(--spacing-sm);
+ }
+}
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
new file mode 100644
index 0000000..5ed0f2a
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -0,0 +1,91 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CommentsListComponent from './comments-list';
+import { comments } from './comments-list.fixture';
+
+const saveComment = async () => {
+ /** Do nothing. */
+};
+
+/**
+ * CommentsList - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Layout/CommentsList',
+ component: CommentsListComponent,
+ args: {
+ saveComment,
+ },
+ argTypes: {
+ comments: {
+ control: {
+ type: null,
+ },
+ description: 'An array of comments.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ depth: {
+ control: {
+ type: 'number',
+ min: 0,
+ max: 4,
+ },
+ description: 'The maximum depth. Use `0` to not display nested comments.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ saveComment: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to save the comment form data.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof CommentsListComponent>;
+
+const Template: ComponentStory<typeof CommentsListComponent> = (args) => (
+ <CommentsListComponent {...args} />
+);
+
+/**
+ * Layout Stories - Without child comments
+ */
+export const WithoutChildComments = Template.bind({});
+WithoutChildComments.args = {
+ comments,
+ depth: 0,
+};
+
+/**
+ * Layout Stories - With child comments
+ */
+export const WithChildComments = Template.bind({});
+WithChildComments.args = {
+ comments,
+ depth: 1,
+};
diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx
new file mode 100644
index 0000000..b0a2467
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.test.tsx
@@ -0,0 +1,12 @@
+import { render } from '@test-utils';
+import { saveComment } from './comment.fixture';
+import CommentsList from './comments-list';
+import { comments } from './comments-list.fixture';
+
+describe('CommentsList', () => {
+ it('renders a comments list', () => {
+ render(
+ <CommentsList comments={comments} depth={1} saveComment={saveComment} />
+ );
+ });
+});
diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx
new file mode 100644
index 0000000..97eccb7
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.tsx
@@ -0,0 +1,60 @@
+import SingleComment, {
+ type CommentProps,
+} from '@components/organisms/layout/comment';
+import { Comment } from '@ts/types/app';
+import { FC } from 'react';
+import styles from './comments-list.module.scss';
+
+export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & {
+ /**
+ * An array of comments.
+ */
+ comments: Comment[];
+ /**
+ * The maximum depth. Use `0` to not display nested comments.
+ */
+ depth: 0 | 1 | 2 | 3 | 4;
+};
+
+/**
+ * CommentsList component
+ *
+ * Render a comments list.
+ */
+const CommentsList: FC<CommentsListProps> = ({
+ comments,
+ depth,
+ Notice,
+ saveComment,
+}) => {
+ /**
+ * Get each comment wrapped in a list item.
+ *
+ * @param {Comment[]} commentsList - An array of comments.
+ * @returns {JSX.Element[]} The list items.
+ */
+ const getItems = (
+ commentsList: Comment[],
+ startLevel: number
+ ): JSX.Element[] => {
+ const isLastLevel = startLevel === depth;
+
+ return commentsList.map(({ replies, ...comment }) => (
+ <li key={comment.id} className={styles.item}>
+ <SingleComment
+ canReply={!isLastLevel}
+ Notice={Notice}
+ saveComment={saveComment}
+ {...comment}
+ />
+ {replies && !isLastLevel && (
+ <ol className={styles.list}>{getItems(replies, startLevel + 1)}</ol>
+ )}
+ </li>
+ ));
+ };
+
+ return <ol className={styles.list}>{getItems(comments, 0)}</ol>;
+};
+
+export default CommentsList;
diff --git a/src/components/organisms/layout/footer.module.scss b/src/components/organisms/layout/footer.module.scss
new file mode 100644
index 0000000..c180e86
--- /dev/null
+++ b/src/components/organisms/layout/footer.module.scss
@@ -0,0 +1,41 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ flex-flow: column wrap;
+ gap: var(--spacing-xs);
+ place-items: center;
+ place-content: center;
+ padding: var(--spacing-md) 0 calc(var(--toolbar-size) + var(--spacing-md));
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ --toolbar-size: 0px;
+
+ flex-flow: row wrap;
+ font-size: var(--font-size-sm);
+ }
+ }
+}
+
+.nav {
+ display: flex;
+ flex-flow: row wrap;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ &::before {
+ content: "\2022";
+ margin-right: var(--spacing-2xs);
+ }
+ }
+ }
+}
+
+.back-to-top {
+ position: fixed;
+ bottom: calc(var(--toolbar-size, 0px) + var(--spacing-md));
+ right: var(--spacing-md);
+ transition: all 0.4s ease-in 0s;
+}
diff --git a/src/components/organisms/layout/footer.stories.tsx b/src/components/organisms/layout/footer.stories.tsx
new file mode 100644
index 0000000..bd5a744
--- /dev/null
+++ b/src/components/organisms/layout/footer.stories.tsx
@@ -0,0 +1,90 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import FooterComponent from './footer';
+
+/**
+ * Footer - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Layout',
+ component: FooterComponent,
+ argTypes: {
+ backToTopClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the back to top button.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the footer element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ copyright: {
+ description: 'The copyright information.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ navItems: {
+ description: 'The footer nav items.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ topId: {
+ control: {
+ type: 'text',
+ },
+ description:
+ 'An element id (without hashtag) used as target by back to top button.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof FooterComponent>;
+
+const Template: ComponentStory<typeof FooterComponent> = (args) => (
+ <FooterComponent {...args} />
+);
+
+const copyright = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems = [{ id: 'legal-notice', href: '#', label: 'Legal notice' }];
+
+/**
+ * Layout Stories - Footer
+ */
+export const Footer = Template.bind({});
+Footer.args = {
+ copyright,
+ navItems,
+ topId: 'top',
+};
diff --git a/src/components/organisms/layout/footer.test.tsx b/src/components/organisms/layout/footer.test.tsx
new file mode 100644
index 0000000..bc23732
--- /dev/null
+++ b/src/components/organisms/layout/footer.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import Footer, { type FooterProps } from './footer';
+
+const copyright: FooterProps['copyright'] = {
+ dates: { start: '2017', end: '2022' },
+ owner: 'Lorem ipsum',
+ icon: 'CC',
+};
+
+const navItems: FooterProps['navItems'] = [
+ { id: 'legal-notice', href: '#', label: 'Legal notice' },
+];
+
+describe('Footer', () => {
+ it('renders the website copyright', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(screen.getByText(copyright.owner)).toBeInTheDocument();
+ });
+
+ it('renders a back to top link', () => {
+ render(<Footer copyright={copyright} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: 'Back to top' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders some nav items', () => {
+ render(<Footer copyright={copyright} navItems={navItems} topId="top" />);
+ expect(
+ screen.getByRole('link', { name: navItems[0].label })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
new file mode 100644
index 0000000..c60afec
--- /dev/null
+++ b/src/components/organisms/layout/footer.tsx
@@ -0,0 +1,77 @@
+import Copyright, {
+ type CopyrightProps,
+} from '@components/atoms/layout/copyright';
+import BackToTop, {
+ type BackToTopProps,
+} from '@components/molecules/buttons/back-to-top';
+import Nav, { type NavItem } from '@components/molecules/nav/nav';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './footer.module.scss';
+
+export type FooterProps = {
+ /**
+ * Set additional classnames to the back to top button.
+ */
+ backToTopClassName?: BackToTopProps['className'];
+ /**
+ * Set additional classnames to the footer element.
+ */
+ className?: string;
+ /**
+ * Set the copyright information.
+ */
+ copyright: CopyrightProps;
+ /**
+ * The footer nav items.
+ */
+ navItems?: NavItem[];
+ /**
+ * An element id (without hashtag) used as anchor for back to top button.
+ */
+ topId: string;
+};
+
+/**
+ * Footer component
+ *
+ * Renders a footer with copyright and nav;
+ */
+const Footer: FC<FooterProps> = ({
+ backToTopClassName,
+ className = '',
+ copyright,
+ navItems,
+ topId,
+}) => {
+ const intl = useIntl();
+ const ariaLabel = intl.formatMessage({
+ defaultMessage: 'Footer',
+ description: 'Footer: an accessible name for footer nav',
+ id: 'd4N8nD',
+ });
+
+ return (
+ <footer className={`${styles.wrapper} ${className}`}>
+ <Copyright
+ dates={copyright.dates}
+ owner={copyright.owner}
+ icon={copyright.icon}
+ />
+ {navItems && (
+ <Nav
+ aria-label={ariaLabel}
+ kind="footer"
+ items={navItems}
+ className={styles.nav}
+ />
+ )}
+ <BackToTop
+ target={topId}
+ className={`${styles['back-to-top']} ${backToTopClassName}`}
+ />
+ </footer>
+ );
+};
+
+export default Footer;
diff --git a/src/components/organisms/layout/header.module.scss b/src/components/organisms/layout/header.module.scss
new file mode 100644
index 0000000..a98cf45
--- /dev/null
+++ b/src/components/organisms/layout/header.module.scss
@@ -0,0 +1,50 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: grid;
+ grid-template-columns:
+ minmax(0, 1fr) min(calc(100vw - calc(var(--spacing-md) * 2)), 100ch)
+ minmax(0, 1fr);
+ align-items: center;
+ padding: var(--spacing-md) 0 var(--spacing-lg);
+
+ .toolbar {
+ justify-content: space-around;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ z-index: 5;
+ background: var(--color-bg);
+ border-top: fun.convert-px(4) solid;
+ border-image: radial-gradient(
+ ellipse at top,
+ var(--color-primary-lighter) 20%,
+ var(--color-primary) 100%
+ )
+ 1;
+ box-shadow: 0 fun.convert-px(-2) fun.convert-px(3) fun.convert-px(-1)
+ var(--color-shadow-dark);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ justify-content: flex-end;
+ width: auto;
+ position: relative;
+ left: unset;
+ background: inherit;
+ border: none;
+ box-shadow: none;
+ }
+ }
+ }
+}
+
+.body {
+ grid-column: 2;
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-md);
+}
diff --git a/src/components/organisms/layout/header.stories.tsx b/src/components/organisms/layout/header.stories.tsx
new file mode 100644
index 0000000..0507e89
--- /dev/null
+++ b/src/components/organisms/layout/header.stories.tsx
@@ -0,0 +1,153 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import HeaderComponent from './header';
+
+/**
+ * Header - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Layout',
+ component: HeaderComponent,
+ args: {
+ ackeeStorageKey: 'ackee-tracking',
+ isHome: false,
+ motionStorageKey: 'reduced-motion',
+ searchPage: '#',
+ withLink: false,
+ },
+ argTypes: {
+ ackeeStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set Ackee settings local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ baseline: {
+ control: {
+ type: 'text',
+ },
+ description: 'The branding baseline.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the header wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isHome: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the current page is homepage or not.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ motionStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set Reduced motion settings local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ nav: {
+ description: 'The main navigation items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ photo: {
+ control: {
+ type: 'text',
+ },
+ description: 'The branding photo.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ searchPage: {
+ control: {
+ type: 'text',
+ },
+ description: 'The search results page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The website title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ withLink: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Wrap the website title with a link to homepage.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof HeaderComponent>;
+
+const Template: ComponentStory<typeof HeaderComponent> = (args) => (
+ <HeaderComponent {...args} />
+);
+
+const nav = [
+ { id: 'home-link', href: '#', label: 'Home' },
+ { id: 'blog-link', href: '#', label: 'Blog' },
+ { id: 'cv-link', href: '#', label: 'CV' },
+ { id: 'contact-link', href: '#', label: 'Contact' },
+];
+
+/**
+ * Layout Stories - Header
+ */
+export const Header = Template.bind({});
+Header.args = {
+ nav,
+ photo: 'http://placeimg.com/640/480/people',
+ title: 'Website title',
+};
diff --git a/src/components/organisms/layout/header.test.tsx b/src/components/organisms/layout/header.test.tsx
new file mode 100644
index 0000000..414d96f
--- /dev/null
+++ b/src/components/organisms/layout/header.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@test-utils';
+import Header from './header';
+
+const nav = [
+ { id: 'home-link', href: '#', label: 'Home' },
+ { id: 'blog-link', href: '#', label: 'Blog' },
+ { id: 'cv-link', href: '#', label: 'CV' },
+ { id: 'contact-link', href: '#', label: 'Contact' },
+];
+
+const photo = 'http://placeimg.com/640/480/nightlife';
+
+const title = 'Assumenda quis quod';
+
+describe('Header', () => {
+ it('renders the website title', () => {
+ render(
+ <Header
+ ackeeStorageKey="ackee-tracking"
+ isHome={true}
+ motionStorageKey="reduced-motion"
+ nav={nav}
+ photo={photo}
+ searchPage="#"
+ title={title}
+ />
+ );
+ expect(
+ screen.getByRole('heading', { level: 1, name: title })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the main nav', () => {
+ render(
+ <Header
+ ackeeStorageKey="ackee-tracking"
+ motionStorageKey="reduced-motion"
+ nav={nav}
+ photo={photo}
+ searchPage="#"
+ title={title}
+ />
+ );
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/header.tsx b/src/components/organisms/layout/header.tsx
new file mode 100644
index 0000000..f6212c3
--- /dev/null
+++ b/src/components/organisms/layout/header.tsx
@@ -0,0 +1,48 @@
+import Branding, {
+ type BrandingProps,
+} from '@components/molecules/layout/branding';
+import { FC } from 'react';
+import Toolbar, { type ToolbarProps } from '../toolbar/toolbar';
+import styles from './header.module.scss';
+
+export type HeaderProps = BrandingProps &
+ Pick<
+ ToolbarProps,
+ 'ackeeStorageKey' | 'motionStorageKey' | 'nav' | 'searchPage'
+ > & {
+ /**
+ * Set additional classnames to the header element.
+ */
+ className?: string;
+ };
+
+/**
+ * Header component
+ *
+ * Render the website header.
+ */
+const Header: FC<HeaderProps> = ({
+ ackeeStorageKey,
+ className,
+ motionStorageKey,
+ nav,
+ searchPage,
+ ...props
+}) => {
+ return (
+ <header className={`${styles.wrapper} ${className}`}>
+ <div className={styles.body}>
+ <Branding {...props} />
+ <Toolbar
+ ackeeStorageKey={ackeeStorageKey}
+ className={styles.toolbar}
+ motionStorageKey={motionStorageKey}
+ nav={nav}
+ searchPage={searchPage}
+ />
+ </div>
+ </header>
+ );
+};
+
+export default Header;
diff --git a/src/components/organisms/layout/no-results.stories.tsx b/src/components/organisms/layout/no-results.stories.tsx
new file mode 100644
index 0000000..aa2e51e
--- /dev/null
+++ b/src/components/organisms/layout/no-results.stories.tsx
@@ -0,0 +1,28 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import NoResultsComponent from './no-results';
+
+export default {
+ title: 'Organisms/Layout',
+ component: NoResultsComponent,
+ argTypes: {
+ searchPage: {
+ control: {
+ type: 'text',
+ },
+ description: 'The search results page.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof NoResultsComponent>;
+
+const Template: ComponentStory<typeof NoResultsComponent> = (args) => (
+ <NoResultsComponent {...args} />
+);
+
+export const NoResults = Template.bind({});
+NoResults.args = {
+ searchPage: '#',
+};
diff --git a/src/components/organisms/layout/no-results.test.tsx b/src/components/organisms/layout/no-results.test.tsx
new file mode 100644
index 0000000..7f57177
--- /dev/null
+++ b/src/components/organisms/layout/no-results.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import NoResults from './no-results';
+
+describe('NoResults', () => {
+ it('renders a no results text', () => {
+ render(<NoResults searchPage="#" />);
+ expect(screen.getByText(/No results/gi)).toBeInTheDocument();
+ });
+
+ it('renders a search form', () => {
+ render(<NoResults searchPage="#" />);
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/no-results.tsx b/src/components/organisms/layout/no-results.tsx
new file mode 100644
index 0000000..2245dbf
--- /dev/null
+++ b/src/components/organisms/layout/no-results.tsx
@@ -0,0 +1,38 @@
+import SearchForm, {
+ type SearchFormProps,
+} from '@components/organisms/forms/search-form';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+
+export type NoResultsProps = Pick<SearchFormProps, 'searchPage'>;
+
+/**
+ * NoResults component
+ *
+ * Renders a no results text with a search form.
+ */
+const NoResults: FC<NoResultsProps> = ({ searchPage }) => {
+ const intl = useIntl();
+
+ return (
+ <>
+ <p>
+ {intl.formatMessage({
+ defaultMessage: 'No results found.',
+ description: 'NoResults: no results',
+ id: '5O2vpy',
+ })}
+ </p>
+ <p>
+ {intl.formatMessage({
+ defaultMessage: 'Would you like to try a new search?',
+ description: 'NoResults: try a new search message',
+ id: 'DVBwfu',
+ })}
+ </p>
+ <SearchForm hideLabel={true} searchPage={searchPage} />
+ </>
+ );
+};
+
+export default NoResults;
diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss
new file mode 100644
index 0000000..895bae5
--- /dev/null
+++ b/src/components/organisms/layout/overview.module.scss
@@ -0,0 +1,44 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-border);
+
+ .meta {
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ min(calc(100vw - (var(--spacing-md) * 2)), 23ch)
+ );
+ row-gap: var(--spacing-2xs);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ grid-template-columns: repeat(
+ auto-fit,
+ min(calc(100vw - (var(--spacing-md) * 2)), 20ch)
+ );
+ }
+ }
+
+ &--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);
+ }
+ }
+ }
+ }
+
+ .cover {
+ width: fit-content;
+ max-height: fun.convert-px(175);
+ margin-bottom: var(--spacing-sm);
+ padding: var(--spacing-2xs);
+ border: fun.convert-px(1) solid var(--color-border);
+ }
+}
diff --git a/src/components/organisms/layout/overview.stories.tsx b/src/components/organisms/layout/overview.stories.tsx
new file mode 100644
index 0000000..26f7ba0
--- /dev/null
+++ b/src/components/organisms/layout/overview.stories.tsx
@@ -0,0 +1,77 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Overview, { OverviewMeta } from './overview';
+
+/**
+ * Overview - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Layout/Overview',
+ component: Overview,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the overview wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ cover: {
+ description: 'The overview cover',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'object',
+ required: false,
+ value: {},
+ },
+ },
+ meta: {
+ description: 'The overview meta.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof Overview>;
+
+const Template: ComponentStory<typeof Overview> = (args) => (
+ <Overview {...args} />
+);
+
+const cover = {
+ alt: 'picture',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const meta: OverviewMeta = {
+ creation: { date: '2022-05-09' },
+ license: 'Dignissimos ratione veritatis',
+};
+
+/**
+ * Overview Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ meta,
+};
+
+/**
+ * Overview Stories - With cover
+ */
+export const WithCover = Template.bind({});
+WithCover.args = {
+ cover,
+ meta,
+};
diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx
new file mode 100644
index 0000000..b40a785
--- /dev/null
+++ b/src/components/organisms/layout/overview.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from '@test-utils';
+import Overview, { type OverviewMeta } from './overview';
+
+const cover = {
+ alt: 'Incidunt unde quam',
+ height: 480,
+ src: 'http://placeimg.com/640/480/cats',
+ width: 640,
+};
+
+const data: OverviewMeta = {
+ creation: { date: '2022-05-09' },
+ license: 'Dignissimos ratione veritatis',
+};
+
+describe('Overview', () => {
+ it('renders some data', () => {
+ render(<Overview meta={data} />);
+ expect(screen.getByText(data.license!)).toBeInTheDocument();
+ });
+
+ it('renders a cover', () => {
+ render(<Overview cover={cover} meta={data} />);
+ expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
new file mode 100644
index 0000000..b110e68
--- /dev/null
+++ b/src/components/organisms/layout/overview.tsx
@@ -0,0 +1,61 @@
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Meta, { type MetaData } from '@components/molecules/layout/meta';
+import { FC } from 'react';
+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.
+ */
+ className?: string;
+ /**
+ * The overview cover.
+ */
+ cover?: Pick<ResponsiveImageProps, 'alt' | 'src' | 'width' | 'height'>;
+ /**
+ * The overview meta.
+ */
+ meta: OverviewMeta;
+};
+
+/**
+ * Overview component
+ *
+ * Render an overview.
+ */
+const Overview: FC<OverviewProps> = ({ className = '', cover, meta }) => {
+ const { technologies, ...remainingMeta } = meta;
+ const metaModifier = technologies ? styles['meta--has-techno'] : '';
+
+ return (
+ <div className={`${styles.wrapper} ${className}`}>
+ {cover && (
+ <ResponsiveImage
+ className={styles.cover}
+ objectFit="contain"
+ {...cover}
+ />
+ )}
+ <Meta
+ data={{ ...remainingMeta, technologies }}
+ layout="inline"
+ className={`${styles.meta} ${metaModifier}`}
+ withSeparator={false}
+ />
+ </div>
+ );
+};
+
+export default Overview;
diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
new file mode 100644
index 0000000..97a746f
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -0,0 +1,63 @@
+import { type Post } 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.';
+
+export const introPost2 =
+ '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.';
+
+export const introPost3 =
+ '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.';
+
+export const cover = {
+ alt: 'cover',
+ height: 480,
+ src: 'http://placeimg.com/640/480',
+ width: 640,
+};
+
+export const posts: Post[] = [
+ {
+ intro: introPost1,
+ id: 'post-1',
+ meta: {
+ cover,
+ dates: { publication: '2022-02-26' },
+ wordsCount: introPost1.split(' ').length,
+ thematics: [
+ { id: 1, name: 'Cat 1', url: '#' },
+ { id: 2, name: 'Cat 2', url: '#' },
+ ],
+ commentsCount: 1,
+ },
+ title: 'Ratione velit fuga',
+ url: '#',
+ },
+ {
+ intro: introPost2,
+ id: 'post-2',
+ meta: {
+ dates: { publication: '2022-02-20' },
+ wordsCount: introPost2.split(' ').length,
+ thematics: [{ id: 2, name: 'Cat 2', url: '#' }],
+ commentsCount: 0,
+ },
+ title: 'Debitis laudantium laudantium',
+ url: '#',
+ },
+ {
+ intro: introPost3,
+ id: 'post-3',
+ meta: {
+ cover,
+ dates: { publication: '2021-12-20' },
+ wordsCount: introPost3.split(' ').length,
+ thematics: [{ id: 1, name: 'Cat 1', url: '#' }],
+ commentsCount: 3,
+ },
+ title: 'Quaerat ut corporis',
+ url: '#',
+ },
+];
+
+export const searchPage = '#';
diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss
new file mode 100644
index 0000000..b09bb12
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.module.scss
@@ -0,0 +1,62 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.section {
+ &:not(:last-of-type) {
+ margin-bottom: var(--spacing-md);
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ display: grid;
+ grid-template-columns: fun.convert-px(150) minmax(0, 1fr);
+ align-items: first baseline;
+ margin-left: fun.convert-px(-150);
+ }
+ }
+}
+
+.list {
+ @extend %reset-ordered-list;
+
+ .item {
+ border-bottom: fun.convert-px(1) solid var(--color-border);
+
+ &:not(:last-child) {
+ margin-bottom: var(--spacing-md);
+ }
+ }
+}
+
+.year {
+ padding-bottom: fun.convert-px(3);
+ background: linear-gradient(
+ to top,
+ var(--color-primary-dark) 0.3rem,
+ transparent 0.3rem
+ )
+ 0 0 / 3rem 100% no-repeat;
+ font-size: var(--font-size-2xl);
+ font-weight: 500;
+ text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ grid-column: 1;
+ justify-self: end;
+ padding-right: var(--spacing-lg);
+ position: sticky;
+ top: var(--spacing-xs);
+ }
+
+ @include mix.dimensions("lg") {
+ padding-right: var(--spacing-xl);
+ }
+ }
+}
+
+.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
new file mode 100644
index 0000000..bff1f28
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -0,0 +1,194 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import PostsList from './posts-list';
+import { posts, searchPage } from './posts-list.fixture';
+
+/**
+ * PostsList - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Layout/PostsList',
+ component: PostsList,
+ args: {
+ byYear: false,
+ isLoading: false,
+ pageNumber: 1,
+ showLoadMoreBtn: false,
+ siblings: 1,
+ titleLevel: 2,
+ },
+ argTypes: {
+ baseUrl: {
+ control: {
+ type: 'text',
+ },
+ description: 'The pagination base url.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: '/page/' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ byYear: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'True to display the posts by year.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ isLoading: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the data is loading.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ loadMore: {
+ control: {
+ type: null,
+ },
+ description: 'A function to load more posts on button click.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ pageNumber: {
+ control: {
+ type: 'number',
+ },
+ description: 'The current page number.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 1 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ posts: {
+ description: 'The posts data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ showLoadMoreBtn: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the load more button should be visible.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ siblings: {
+ control: {
+ type: 'number',
+ },
+ description: 'The number of page siblings inside pagination.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 1 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ titleLevel: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'The title level (hn).',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 2 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ total: {
+ control: {
+ type: 'number',
+ },
+ description: 'The number of posts.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof PostsList>;
+
+const Template: ComponentStory<typeof PostsList> = (args) => (
+ <PostsList {...args} />
+);
+
+/**
+ * PostsList Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ posts,
+ searchPage,
+ total: posts.length,
+};
+
+/**
+ * PostsList Stories - By years
+ */
+export const ByYears = Template.bind({});
+ByYears.args = {
+ posts,
+ byYear: true,
+ searchPage,
+ total: posts.length,
+};
+ByYears.decorators = [
+ (Story) => (
+ <div style={{ marginLeft: 150 }}>
+ <Story />
+ </div>
+ ),
+];
+
+/**
+ * PostsList Stories - No results
+ */
+export const NoResults = Template.bind({});
+NoResults.args = {
+ posts: [],
+ searchPage,
+ total: posts.length,
+};
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
new file mode 100644
index 0000000..e58a974
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from '@test-utils';
+import PostsList from './posts-list';
+import { posts, searchPage } from './posts-list.fixture';
+
+describe('PostsList', () => {
+ it('renders the correct number of posts', () => {
+ render(
+ <PostsList posts={posts} total={posts.length} searchPage={searchPage} />
+ );
+ expect(screen.getAllByRole('article')).toHaveLength(posts.length);
+ });
+
+ it('renders the number of loaded posts', () => {
+ render(
+ <PostsList posts={posts} total={posts.length} searchPage={searchPage} />
+ );
+ const info = `${posts.length} loaded articles out of a total of ${posts.length}`;
+ expect(screen.getByText(info)).toBeInTheDocument();
+ });
+
+ it('renders a load more button', () => {
+ render(
+ <PostsList
+ posts={posts}
+ total={posts.length}
+ showLoadMoreBtn={true}
+ searchPage={searchPage}
+ />
+ );
+ expect(
+ screen.getByRole('button', { name: /Load more/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a search form if no results', () => {
+ render(
+ <PostsList
+ posts={[]}
+ total={0}
+ showLoadMoreBtn={true}
+ searchPage={searchPage}
+ />
+ );
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
new file mode 100644
index 0000000..24869fd
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -0,0 +1,239 @@
+import Button from '@components/atoms/buttons/button';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import ProgressBar from '@components/atoms/loaders/progress-bar';
+import Spinner from '@components/atoms/loaders/spinner';
+import Pagination, {
+ type PaginationProps,
+} from '@components/molecules/nav/pagination';
+import useIsMounted from '@utils/hooks/use-is-mounted';
+import useSettings from '@utils/hooks/use-settings';
+import { FC, Fragment, useRef } from 'react';
+import { useIntl } from 'react-intl';
+import NoResults, { NoResultsProps } from './no-results';
+import styles from './posts-list.module.scss';
+import Summary, { type SummaryProps } from './summary';
+
+export type Post = Omit<SummaryProps, 'titleLevel'> & {
+ /**
+ * The post id.
+ */
+ id: string | number;
+};
+
+export type YearCollection = {
+ [key: string]: Post[];
+};
+
+export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> &
+ Pick<NoResultsProps, 'searchPage'> & {
+ /**
+ * True to display the posts by year. Default: false.
+ */
+ byYear?: boolean;
+ /**
+ * Determine if the data is loading.
+ */
+ isLoading?: boolean;
+ /**
+ * Load more button handler.
+ */
+ loadMore?: () => void;
+ /**
+ * The current page number. Default: 1.
+ */
+ pageNumber?: number;
+ /**
+ * The posts data.
+ */
+ posts: Post[];
+ /**
+ * Determine if the load more button should be visible.
+ */
+ showLoadMoreBtn?: boolean;
+ /**
+ * The posts heading level (hn).
+ */
+ titleLevel?: HeadingLevel;
+ /**
+ * The total posts number.
+ */
+ total: number;
+ };
+
+/**
+ * Create a collection of posts sorted by year.
+ *
+ * @param {Posts[]} data - A collection of posts.
+ * @returns {YearCollection} The posts sorted by year.
+ */
+const sortPostsByYear = (data: Post[]): YearCollection => {
+ const yearCollection: YearCollection = {};
+
+ data.forEach((post) => {
+ const postYear = new Date(post.meta.dates.publication)
+ .getFullYear()
+ .toString();
+ yearCollection[postYear] = [...(yearCollection[postYear] || []), post];
+ });
+
+ return yearCollection;
+};
+
+/**
+ * PostsList component
+ *
+ * Render a list of post summaries.
+ */
+const PostsList: FC<PostsListProps> = ({
+ baseUrl,
+ byYear = false,
+ isLoading = false,
+ loadMore,
+ pageNumber = 1,
+ posts,
+ searchPage,
+ showLoadMoreBtn = false,
+ siblings,
+ titleLevel,
+ total,
+}) => {
+ const intl = useIntl();
+ const listRef = useRef<HTMLOListElement>(null);
+ const lastPostRef = useRef<HTMLSpanElement>(null);
+ const isMounted = useIsMounted(listRef);
+ const { blog } = useSettings();
+
+ const lastPostId = posts.length ? posts[posts.length - 1].id : 0;
+
+ /**
+ * Retrieve the list of posts.
+ *
+ * @param {Posts[]} allPosts - A collection fo posts.
+ * @param {HeadingLevel} [headingLevel] - The posts heading level (hn).
+ * @returns {JSX.Element} The list of posts.
+ */
+ const getList = (
+ allPosts: Post[],
+ headingLevel: HeadingLevel = 2
+ ): JSX.Element => {
+ return (
+ <ol className={styles.list} ref={listRef}>
+ {allPosts.map(({ id, ...post }) => (
+ <Fragment key={id}>
+ <li className={styles.item}>
+ <Summary {...post} titleLevel={headingLevel} />
+ </li>
+ {id === lastPostId && (
+ <li>
+ <span ref={lastPostRef} tabIndex={-1} />
+ </li>
+ )}
+ </Fragment>
+ ))}
+ </ol>
+ );
+ };
+
+ /**
+ * Retrieve the list of posts.
+ *
+ * @returns {JSX.Element | JSX.Element[]} The posts list.
+ */
+ const getPosts = (): JSX.Element | JSX.Element[] => {
+ const firstLevel = titleLevel || 2;
+ if (!byYear) return getList(posts, firstLevel);
+
+ const postsPerYear = sortPostsByYear(posts);
+ const years = Object.keys(postsPerYear).reverse();
+ const nextLevel = (firstLevel + 1) as HeadingLevel;
+
+ return years.map((year) => {
+ return (
+ <section key={year} className={styles.section}>
+ <Heading level={firstLevel} className={styles.year}>
+ {year}
+ </Heading>
+ {getList(postsPerYear[year], nextLevel)}
+ </section>
+ );
+ });
+ };
+
+ 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 loadMoreBody = intl.formatMessage({
+ defaultMessage: 'Load more articles?',
+ id: 'uaqd5F',
+ description: 'PostsList: load more button',
+ });
+
+ /**
+ * Load more posts handler.
+ */
+ const loadMorePosts = () => {
+ if (lastPostRef.current) {
+ lastPostRef.current.focus();
+ }
+
+ loadMore && loadMore();
+ };
+
+ const getProgressBar = () => {
+ return (
+ <>
+ <ProgressBar
+ min={1}
+ max={total}
+ current={posts.length}
+ info={progressInfo}
+ />
+ {showLoadMoreBtn && (
+ <Button
+ kind="tertiary"
+ onClick={loadMorePosts}
+ disabled={isLoading}
+ className={styles.btn}
+ >
+ {loadMoreBody}
+ </Button>
+ )}
+ </>
+ );
+ };
+
+ const getPagination = () => {
+ return posts.length <= blog.postsPerPage ? (
+ <Pagination
+ baseUrl={baseUrl}
+ current={pageNumber}
+ perPage={blog.postsPerPage}
+ siblings={siblings}
+ total={total}
+ />
+ ) : (
+ <></>
+ );
+ };
+
+ if (posts.length === 0) {
+ return <NoResults searchPage={searchPage} />;
+ }
+
+ return (
+ <>
+ {getPosts()}
+ {isLoading && <Spinner />}
+ {isMounted ? getProgressBar() : getPagination()}
+ </>
+ );
+};
+
+export default PostsList;
diff --git a/src/components/organisms/layout/summary.fixture.tsx b/src/components/organisms/layout/summary.fixture.tsx
new file mode 100644
index 0000000..bb3ebcb
--- /dev/null
+++ b/src/components/organisms/layout/summary.fixture.tsx
@@ -0,0 +1,25 @@
+import { type SummaryMeta } from './summary';
+
+export const cover = {
+ alt: 'A cover',
+ height: 480,
+ src: 'http://placeimg.com/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.module.scss b/src/components/organisms/layout/summary.module.scss
new file mode 100644
index 0000000..62dfc0e
--- /dev/null
+++ b/src/components/organisms/layout/summary.module.scss
@@ -0,0 +1,121 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ column-gap: var(--spacing-md);
+ row-gap: var(--spacing-sm);
+ padding: var(--spacing-2xs) 0 var(--spacing-lg);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
+ border: fun.convert-px(1) solid var(--color-primary-dark);
+ border-radius: fun.convert-px(3);
+ box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+ var(--color-shadow),
+ fun.convert-px(3) fun.convert-px(3) fun.convert-px(3) fun.convert-px(-1)
+ var(--color-shadow-light),
+ fun.convert-px(5) fun.convert-px(5) fun.convert-px(7) fun.convert-px(-1)
+ var(--color-shadow-light);
+ }
+
+ @include mix.dimensions("sm") {
+ grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
+ grid-template-rows: repeat(3, max-content);
+ }
+ }
+
+ &:hover {
+ .icon {
+ --icon-size: #{fun.convert-px(35)};
+
+ :global {
+ animation: pulse 1.5s ease-in-out 0.2s infinite;
+ }
+ }
+ }
+}
+
+.cover {
+ display: inline-flex;
+ flex-flow: column nowrap;
+ justify-content: center;
+ width: auto;
+ height: fun.convert-px(100);
+ max-width: 100%;
+ border: fun.convert-px(1) solid var(--color-border);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 2;
+ grid-row: 1;
+ }
+ }
+}
+
+.header {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 1;
+ grid-row: 1;
+ align-self: center;
+ }
+ }
+}
+
+.body {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ }
+}
+
+.footer {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ grid-column: 2;
+ grid-row: 2 / 4;
+ }
+ }
+}
+
+.link {
+ display: block;
+ width: fit-content;
+}
+
+.title {
+ margin: 0;
+ background: none;
+ color: inherit;
+ font-size: var(--font-size-2xl);
+ text-shadow: none;
+}
+
+.read-more {
+ display: flex;
+ flex-flow: row nowrap;
+ column-gap: var(--spacing-xs);
+ width: max-content;
+ margin: var(--spacing-sm) 0 0;
+}
+
+.meta {
+ 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;
+ margin-top: 0;
+ }
+ }
+}
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
new file mode 100644
index 0000000..0b91e24
--- /dev/null
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -0,0 +1,107 @@
+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
new file mode 100644
index 0000000..7617c26
--- /dev/null
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@test-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
new file mode 100644
index 0000000..8807878
--- /dev/null
+++ b/src/components/organisms/layout/summary.tsx
@@ -0,0 +1,136 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Arrow from '@components/atoms/icons/arrow';
+import Link from '@components/atoms/links/link';
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Meta, { type MetaData } from '@components/molecules/layout/meta';
+import { type Article, type Meta as MetaType } 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';
+
+export type Cover = Pick<
+ ResponsiveImageProps,
+ '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.
+ */
+const Summary: FC<SummaryProps> = ({
+ intro,
+ meta,
+ title,
+ titleLevel = 2,
+ url,
+}) => {
+ const intl = useIntl();
+ const readMore = intl.formatMessage(
+ {
+ defaultMessage: 'Read more<a11y> about {title}</a11y>',
+ description: 'Summary: read more link',
+ id: 'Zpgv+f',
+ },
+ {
+ title,
+ a11y: (chunks: ReactNode) => (
+ <span className="screen-reader-text">{chunks}</span>
+ ),
+ }
+ );
+ const { author, commentsCount, cover, dates, thematics, topics, wordsCount } =
+ meta;
+ const readingTime = useReadingTime(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`,
+ },
+ };
+ };
+
+ return (
+ <article className={styles.wrapper}>
+ {cover && <ResponsiveImage className={styles.cover} {...cover} />}
+ <header className={styles.header}>
+ <Link href={url} className={styles.link}>
+ <Heading level={titleLevel} className={styles.title}>
+ {title}
+ </Heading>
+ </Link>
+ </header>
+ <div className={styles.body}>
+ <div dangerouslySetInnerHTML={{ __html: intro }} />
+ <ButtonLink target={url} className={styles['read-more']}>
+ <>
+ {readMore}
+ <Arrow direction="right" className={styles.icon} />
+ </>
+ </ButtonLink>
+ </div>
+ <footer className={styles.footer}>
+ <Meta
+ data={getMeta()}
+ layout="column"
+ itemsLayout="stacked"
+ withSeparator={false}
+ className={styles.meta}
+ groupClassName={styles.meta__item}
+ />
+ </footer>
+ </article>
+ );
+};
+
+export default Summary;