aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/organisms')
-rw-r--r--src/components/organisms/forms/comment-form.module.scss8
-rw-r--r--src/components/organisms/forms/comment-form.stories.tsx123
-rw-r--r--src/components/organisms/forms/comment-form.test.tsx23
-rw-r--r--src/components/organisms/forms/comment-form.tsx193
-rw-r--r--src/components/organisms/forms/contact-form.module.scss8
-rw-r--r--src/components/organisms/forms/contact-form.stories.tsx65
-rw-r--r--src/components/organisms/forms/contact-form.test.tsx48
-rw-r--r--src/components/organisms/forms/contact-form.tsx158
-rw-r--r--src/components/organisms/forms/search-form.module.scss58
-rw-r--r--src/components/organisms/forms/search-form.stories.tsx65
-rw-r--r--src/components/organisms/forms/search-form.test.tsx16
-rw-r--r--src/components/organisms/forms/search-form.tsx76
-rw-r--r--src/components/organisms/forms/settings-form.module.scss11
-rw-r--r--src/components/organisms/forms/settings-form.stories.tsx67
-rw-r--r--src/components/organisms/forms/settings-form.test.tsx67
-rw-r--r--src/components/organisms/forms/settings-form.tsx56
-rw-r--r--src/components/organisms/images/gallery.module.scss26
-rw-r--r--src/components/organisms/images/gallery.stories.tsx75
-rw-r--r--src/components/organisms/images/gallery.test.tsx38
-rw-r--r--src/components/organisms/images/gallery.tsx35
-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
-rw-r--r--src/components/organisms/modals/search-modal.module.scss11
-rw-r--r--src/components/organisms/modals/search-modal.stories.tsx47
-rw-r--r--src/components/organisms/modals/search-modal.test.tsx9
-rw-r--r--src/components/organisms/modals/search-modal.tsx37
-rw-r--r--src/components/organisms/modals/settings-modal.module.scss11
-rw-r--r--src/components/organisms/modals/settings-modal.stories.tsx67
-rw-r--r--src/components/organisms/modals/settings-modal.test.tsx14
-rw-r--r--src/components/organisms/modals/settings-modal.tsx51
-rw-r--r--src/components/organisms/toolbar/main-nav.module.scss96
-rw-r--r--src/components/organisms/toolbar/main-nav.stories.tsx91
-rw-r--r--src/components/organisms/toolbar/main-nav.test.tsx33
-rw-r--r--src/components/organisms/toolbar/main-nav.tsx80
-rw-r--r--src/components/organisms/toolbar/search.module.scss3
-rw-r--r--src/components/organisms/toolbar/search.stories.tsx88
-rw-r--r--src/components/organisms/toolbar/search.test.tsx14
-rw-r--r--src/components/organisms/toolbar/search.tsx80
-rw-r--r--src/components/organisms/toolbar/settings.module.scss10
-rw-r--r--src/components/organisms/toolbar/settings.stories.tsx112
-rw-r--r--src/components/organisms/toolbar/settings.test.tsx32
-rw-r--r--src/components/organisms/toolbar/settings.tsx74
-rw-r--r--src/components/organisms/toolbar/toolbar-items.module.scss91
-rw-r--r--src/components/organisms/toolbar/toolbar.module.scss98
-rw-r--r--src/components/organisms/toolbar/toolbar.stories.tsx90
-rw-r--r--src/components/organisms/toolbar/toolbar.test.tsx23
-rw-r--r--src/components/organisms/toolbar/toolbar.tsx77
-rw-r--r--src/components/organisms/widgets/image-widget.module.scss47
-rw-r--r--src/components/organisms/widgets/image-widget.stories.tsx181
-rw-r--r--src/components/organisms/widgets/image-widget.test.tsx59
-rw-r--r--src/components/organisms/widgets/image-widget.tsx69
-rw-r--r--src/components/organisms/widgets/links-list-widget.module.scss75
-rw-r--r--src/components/organisms/widgets/links-list-widget.stories.tsx122
-rw-r--r--src/components/organisms/widgets/links-list-widget.test.tsx32
-rw-r--r--src/components/organisms/widgets/links-list-widget.tsx85
-rw-r--r--src/components/organisms/widgets/sharing.module.scss10
-rw-r--r--src/components/organisms/widgets/sharing.stories.tsx91
-rw-r--r--src/components/organisms/widgets/sharing.test.tsx23
-rw-r--r--src/components/organisms/widgets/sharing.tsx214
-rw-r--r--src/components/organisms/widgets/social-media.module.scss10
-rw-r--r--src/components/organisms/widgets/social-media.stories.tsx61
-rw-r--r--src/components/organisms/widgets/social-media.test.tsx33
-rw-r--r--src/components/organisms/widgets/social-media.tsx41
-rw-r--r--src/components/organisms/widgets/table-of-contents.module.scss4
-rw-r--r--src/components/organisms/widgets/table-of-contents.stories.tsx54
-rw-r--r--src/components/organisms/widgets/table-of-contents.test.tsx12
-rw-r--r--src/components/organisms/widgets/table-of-contents.tsx55
104 files changed, 6769 insertions, 0 deletions
diff --git a/src/components/organisms/forms/comment-form.module.scss b/src/components/organisms/forms/comment-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
new file mode 100644
index 0000000..1a9e7b7
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -0,0 +1,123 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import CommentForm from './comment-form';
+
+const saveComment = async () => {
+ /** Do nothing. */
+};
+
+/**
+ * CommentForm - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: CommentForm,
+ args: {
+ saveComment,
+ titleAlignment: 'left',
+ titleLevel: 2,
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ 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,
+ },
+ },
+ saveComment: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the comment form data.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The form title.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ titleAlignment: {
+ control: {
+ type: 'select',
+ },
+ description: 'The heading alignment.',
+ options: ['center', 'left'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'left' },
+ },
+ type: {
+ name: 'string',
+ 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,
+ },
+ },
+ },
+} as ComponentMeta<typeof CommentForm>;
+
+const Template: ComponentStory<typeof CommentForm> = (args) => (
+ <CommentForm {...args} />
+);
+
+/**
+ * Forms Stories - Comment
+ */
+export const Comment = Template.bind({});
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx
new file mode 100644
index 0000000..c67ad6b
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@test-utils';
+import CommentForm from './comment-form';
+
+const saveComment = async () => {
+ /** Do nothing. */
+};
+const title = 'Cum voluptas voluptatibus';
+
+describe('CommentForm', () => {
+ it('renders a form', () => {
+ render(<CommentForm saveComment={saveComment} />);
+ expect(screen.getByRole('form')).toBeInTheDocument();
+ });
+
+ it('renders an optional title', () => {
+ render(
+ <CommentForm saveComment={saveComment} title={title} titleLevel={2} />
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: title })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
new file mode 100644
index 0000000..b2c725f
--- /dev/null
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -0,0 +1,193 @@
+import Button from '@components/atoms/buttons/button';
+import Form, { type FormProps } from '@components/atoms/forms/form';
+import Heading, {
+ type HeadingProps,
+ type HeadingLevel,
+} from '@components/atoms/headings/heading';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { FC, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './comment-form.module.scss';
+
+export type CommentFormData = {
+ comment: string;
+ email: string;
+ name: string;
+ parentId?: number;
+ website?: string;
+};
+
+export type CommentFormProps = Pick<FormProps, 'className'> & {
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * The comment parent id.
+ */
+ parentId?: number;
+ /**
+ * A callback function to save comment. It takes a function as parameter to
+ * reset the form.
+ */
+ saveComment: (data: CommentFormData, reset: () => void) => Promise<void>;
+ /**
+ * The form title.
+ */
+ title?: string;
+ /**
+ * The form title alignment. Default: left.
+ */
+ titleAlignment?: HeadingProps['alignment'];
+ /**
+ * The title level. Default: 2.
+ */
+ titleLevel?: HeadingLevel;
+};
+
+const CommentForm: FC<CommentFormProps> = ({
+ Notice,
+ parentId,
+ saveComment,
+ title,
+ titleAlignment,
+ titleLevel = 2,
+ ...props
+}) => {
+ const intl = useIntl();
+ const [name, setName] = useState<string>('');
+ const [email, setEmail] = useState<string>('');
+ const [website, setWebsite] = useState<string>('');
+ const [comment, setComment] = useState<string>('');
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setName('');
+ setEmail('');
+ setWebsite('');
+ setComment('');
+ setIsSubmitting(false);
+ };
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'CommentForm: name label',
+ id: 'ZIrTee',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'CommentForm: email label',
+ id: 'Bh7z5v',
+ });
+
+ const websiteLabel = intl.formatMessage({
+ defaultMessage: 'Website:',
+ description: 'CommentForm: website label',
+ id: 'u41qSk',
+ });
+
+ const commentLabel = intl.formatMessage({
+ defaultMessage: 'Comment:',
+ description: 'CommentForm: comment label',
+ id: 'A8hGaK',
+ });
+
+ const formTitle = intl.formatMessage({
+ defaultMessage: 'Comment form',
+ description: 'CommentForm: aria label',
+ id: 'dz2kDV',
+ });
+
+ const formAriaLabel = title ? undefined : formTitle;
+ const formId = 'comment-form-title';
+ const formLabelledBy = title ? formId : undefined;
+
+ /**
+ * Handle form submit.
+ */
+ const submitHandler = () => {
+ setIsSubmitting(true);
+ saveComment({ comment, email, name, parentId, website }, resetForm).then(
+ () => setIsSubmitting(false)
+ );
+ };
+
+ return (
+ <Form
+ onSubmit={submitHandler}
+ aria-label={formAriaLabel}
+ aria-labelledby={formLabelledBy}
+ {...props}
+ >
+ {title && (
+ <Heading id={formId} level={titleLevel} alignment={titleAlignment}>
+ {title}
+ </Heading>
+ )}
+ <LabelledField
+ type="text"
+ id="commenter-name"
+ name="commenter-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="commenter-email"
+ name="commenter-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="commenter-website"
+ name="commenter-website"
+ label={websiteLabel}
+ required={false}
+ value={website}
+ setValue={setWebsite}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="commenter-comment"
+ name="commenter-comment"
+ label={commentLabel}
+ required={true}
+ value={comment}
+ setValue={setComment}
+ className={styles.field}
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Publish',
+ description: 'CommentForm: submit button',
+ id: 'OL0Yzx',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Submitting...',
+ description: 'CommentForm: spinner message on submit',
+ id: 'IY5ew6',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
+
+export default CommentForm;
diff --git a/src/components/organisms/forms/contact-form.module.scss b/src/components/organisms/forms/contact-form.module.scss
new file mode 100644
index 0000000..f3f2646
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.module.scss
@@ -0,0 +1,8 @@
+.field {
+ width: 100%;
+}
+
+.button {
+ display: block;
+ margin: auto;
+}
diff --git a/src/components/organisms/forms/contact-form.stories.tsx b/src/components/organisms/forms/contact-form.stories.tsx
new file mode 100644
index 0000000..191d448
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.stories.tsx
@@ -0,0 +1,65 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ContactForm from './contact-form';
+
+/**
+ * ContactForm - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: ContactForm,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ Notice: {
+ control: {
+ type: null,
+ },
+ description: 'A component to display a success or error message.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
+ sendMail: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to process the contact form data.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ContactForm>;
+
+const Template: ComponentStory<typeof ContactForm> = (args) => (
+ <ContactForm {...args} />
+);
+
+/**
+ * Forms Stories - Contact
+ */
+export const Contact = Template.bind({});
+Contact.args = {
+ sendMail: async (_data, reset: () => void) => {
+ reset();
+ },
+};
diff --git a/src/components/organisms/forms/contact-form.test.tsx b/src/components/organisms/forms/contact-form.test.tsx
new file mode 100644
index 0000000..6225fa9
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@test-utils';
+import ContactForm from './contact-form';
+
+const props = {
+ sendMail: async () => {
+ /** Do nothing. */
+ },
+};
+
+describe('ContactForm', () => {
+ it('renders a contact form', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('form', { name: 'Contact form' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a name field', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('textbox', { name: /^Name:/ })).toBeInTheDocument();
+ });
+
+ it('renders an email field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Email:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an object field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Object:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a message field', () => {
+ render(<ContactForm {...props} />);
+ expect(
+ screen.getByRole('textbox', { name: /^Message:/ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<ContactForm {...props} />);
+ expect(screen.getByRole('button', { name: /^Send/ })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
new file mode 100644
index 0000000..912402c
--- /dev/null
+++ b/src/components/organisms/forms/contact-form.tsx
@@ -0,0 +1,158 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import Spinner from '@components/atoms/loaders/spinner';
+import LabelledField from '@components/molecules/forms/labelled-field';
+import { FC, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './contact-form.module.scss';
+
+export type ContactFormData = {
+ email: string;
+ message: string;
+ name: string;
+ subject: string;
+};
+
+export type ContactFormProps = {
+ /**
+ * Set additional classnames to the form wrapper.
+ */
+ className?: string;
+ /**
+ * Pass a component to print a success/error message.
+ */
+ Notice?: ReactNode;
+ /**
+ * A callback function to send mail.
+ */
+ sendMail: (data: ContactFormData, reset: () => void) => Promise<void>;
+};
+
+/**
+ * ContactForm component
+ *
+ * Render a contact form.
+ */
+const ContactForm: FC<ContactFormProps> = ({
+ className = '',
+ Notice,
+ sendMail,
+}) => {
+ const intl = useIntl();
+ const [name, setName] = useState<string>('');
+ const [email, setEmail] = useState<string>('');
+ const [object, setObject] = useState<string>('');
+ const [message, setMessage] = useState<string>('');
+ const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+
+ /**
+ * Reset all the form fields.
+ */
+ const resetForm = () => {
+ setName('');
+ setEmail('');
+ setObject('');
+ setMessage('');
+ setIsSubmitting(false);
+ };
+
+ const formName = intl.formatMessage({
+ defaultMessage: 'Contact form',
+ description: 'ContactForm: form accessible name',
+ id: 'HFdzae',
+ });
+
+ const nameLabel = intl.formatMessage({
+ defaultMessage: 'Name:',
+ description: 'ContactForm: name label',
+ id: '1dCuCx',
+ });
+
+ const emailLabel = intl.formatMessage({
+ defaultMessage: 'Email:',
+ description: 'ContactForm: email label',
+ id: 'w4B5PA',
+ });
+
+ const objectLabel = intl.formatMessage({
+ defaultMessage: 'Object:',
+ description: 'ContactForm: object label',
+ id: 's8/tyz',
+ });
+
+ const messageLabel = intl.formatMessage({
+ defaultMessage: 'Message:',
+ description: 'ContactForm: message label',
+ id: 'yN5P+m',
+ });
+
+ const submitHandler = async () => {
+ setIsSubmitting(true);
+ sendMail({ email, message, name, subject: object }, resetForm).then(() =>
+ setIsSubmitting(false)
+ );
+ };
+
+ return (
+ <Form aria-label={formName} onSubmit={submitHandler} className={className}>
+ <LabelledField
+ type="text"
+ id="contact-name"
+ name="contact-name"
+ label={nameLabel}
+ required={true}
+ value={name}
+ setValue={setName}
+ className={styles.field}
+ />
+ <LabelledField
+ type="email"
+ id="contact-email"
+ name="contact-email"
+ label={emailLabel}
+ required={true}
+ value={email}
+ setValue={setEmail}
+ className={styles.field}
+ />
+ <LabelledField
+ type="text"
+ id="contact-object"
+ name="contact-object"
+ label={objectLabel}
+ value={object}
+ setValue={setObject}
+ className={styles.field}
+ />
+ <LabelledField
+ type="textarea"
+ id="contact-message"
+ name="contact-message"
+ label={messageLabel}
+ required={true}
+ value={message}
+ setValue={setMessage}
+ className={styles.field}
+ />
+ <Button type="submit" kind="primary" className={styles.button}>
+ {intl.formatMessage({
+ defaultMessage: 'Send',
+ description: 'ContactForm: send button',
+ id: 'VkAnvv',
+ })}
+ </Button>
+ {isSubmitting && (
+ <Spinner
+ message={intl.formatMessage({
+ defaultMessage: 'Sending mail...',
+ description: 'ContactForm: spinner message on submit',
+ id: 'xaqaYQ',
+ })}
+ />
+ )}
+ {Notice}
+ </Form>
+ );
+};
+
+export default ContactForm;
diff --git a/src/components/organisms/forms/search-form.module.scss b/src/components/organisms/forms/search-form.module.scss
new file mode 100644
index 0000000..1d388a4
--- /dev/null
+++ b/src/components/organisms/forms/search-form.module.scss
@@ -0,0 +1,58 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ display: flex;
+ align-items: center;
+ position: relative;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ max-width: 35ch;
+ }
+ }
+}
+
+.btn {
+ position: absolute;
+ right: 0;
+
+ &__icon {
+ transform: scale(0.85);
+ transition: all 0.3s ease-in-out 0s;
+ }
+
+ &:focus {
+ outline: var(--color-primary-light) solid fun.convert-px(3);
+ }
+
+ &:active {
+ outline: none;
+ }
+
+ &:hover &,
+ &:focus & {
+ &__icon {
+ transform: scale(0.85) rotate(20deg) translateY(#{fun.convert-px(3)});
+ }
+ }
+
+ &:active & {
+ &__icon {
+ transform: scale(0.7);
+ }
+ }
+}
+
+.field {
+ width: 100%;
+ padding-right: var(--spacing-lg);
+
+ &:hover ~ .btn {
+ transform: translate(fun.convert-px(-3), fun.convert-px(-3));
+ }
+
+ &:focus ~ .btn {
+ transform: translate(fun.convert-px(3), fun.convert-px(3));
+ }
+}
diff --git a/src/components/organisms/forms/search-form.stories.tsx b/src/components/organisms/forms/search-form.stories.tsx
new file mode 100644
index 0000000..d8c8e1e
--- /dev/null
+++ b/src/components/organisms/forms/search-form.stories.tsx
@@ -0,0 +1,65 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SearchForm from './search-form';
+
+/**
+ * SearchForm - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: SearchForm,
+ args: {
+ hideLabel: false,
+ searchPage: '#',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the form wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ hideLabel: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the input label should be visually hidden.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ searchPage: {
+ control: {
+ type: 'text',
+ },
+ description: 'The search results page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SearchForm>;
+
+const Template: ComponentStory<typeof SearchForm> = (args) => (
+ <SearchForm {...args} />
+);
+
+/**
+ * Forms Stories - Search
+ */
+export const Search = Template.bind({});
+Search.args = {
+ hideLabel: true,
+};
diff --git a/src/components/organisms/forms/search-form.test.tsx b/src/components/organisms/forms/search-form.test.tsx
new file mode 100644
index 0000000..59a2f68
--- /dev/null
+++ b/src/components/organisms/forms/search-form.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@test-utils';
+import SearchForm from './search-form';
+
+describe('SearchForm', () => {
+ it('renders a search input', () => {
+ render(<SearchForm searchPage="#" />);
+ expect(
+ screen.getByRole('searchbox', { name: 'Search for:' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a submit button', () => {
+ render(<SearchForm searchPage="#" />);
+ expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form.tsx
new file mode 100644
index 0000000..1b5f662
--- /dev/null
+++ b/src/components/organisms/forms/search-form.tsx
@@ -0,0 +1,76 @@
+import Button from '@components/atoms/buttons/button';
+import Form from '@components/atoms/forms/form';
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import LabelledField, {
+ type LabelledFieldProps,
+} from '@components/molecules/forms/labelled-field';
+import { useRouter } from 'next/router';
+import { forwardRef, ForwardRefRenderFunction, useId, useState } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './search-form.module.scss';
+
+export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'> & {
+ /**
+ * The search page url.
+ */
+ searchPage: string;
+};
+
+/**
+ * SearchForm component
+ *
+ * Render a search form.
+ */
+const SearchForm: ForwardRefRenderFunction<
+ HTMLInputElement,
+ SearchFormProps
+> = ({ hideLabel, searchPage }, ref) => {
+ const intl = useIntl();
+ const fieldLabel = intl.formatMessage({
+ defaultMessage: 'Search for:',
+ description: 'SearchForm: field accessible label',
+ id: 'X8oujO',
+ });
+ const buttonLabel = intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'SearchForm: button accessible name',
+ id: 'WMqQrv',
+ });
+
+ const router = useRouter();
+ const [value, setValue] = useState<string>('');
+
+ const submitHandler = () => {
+ router.push({ pathname: searchPage, query: { s: value } });
+ setValue('');
+ };
+
+ const id = useId();
+
+ return (
+ <Form grouped={false} onSubmit={submitHandler} className={styles.wrapper}>
+ <LabelledField
+ className={styles.field}
+ hideLabel={hideLabel}
+ id={`search-form-${id}`}
+ label={fieldLabel}
+ name="search-form"
+ ref={ref}
+ setValue={setValue}
+ type="search"
+ value={value}
+ />
+ <Button
+ type="submit"
+ kind="neutral"
+ shape="initial"
+ className={styles.btn}
+ aria-label={buttonLabel}
+ >
+ <MagnifyingGlass className={styles.btn__icon} />
+ </Button>
+ </Form>
+ );
+};
+
+export default forwardRef(SearchForm);
diff --git a/src/components/organisms/forms/settings-form.module.scss b/src/components/organisms/forms/settings-form.module.scss
new file mode 100644
index 0000000..a6a2077
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.module.scss
@@ -0,0 +1,11 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.label {
+ margin-right: auto;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ font-size: var(--font-size-sm);
+ }
+ }
+}
diff --git a/src/components/organisms/forms/settings-form.stories.tsx b/src/components/organisms/forms/settings-form.stories.tsx
new file mode 100644
index 0000000..70e1844
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.stories.tsx
@@ -0,0 +1,67 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SettingsForm from './settings-form';
+
+/**
+ * SettingsModal - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Forms',
+ component: SettingsForm,
+ argTypes: {
+ ackeeStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'The local storage key for Ackee setting.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ motionStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'The local storage key for reduced motion setting.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SettingsForm>;
+
+const Template: ComponentStory<typeof SettingsForm> = (args) => (
+ <SettingsForm {...args} />
+);
+
+/**
+ * Form Stories - Settings
+ */
+export const Settings = Template.bind({});
diff --git a/src/components/organisms/forms/settings-form.test.tsx b/src/components/organisms/forms/settings-form.test.tsx
new file mode 100644
index 0000000..43d546e
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from '@test-utils';
+import SettingsForm from './settings-form';
+
+const ackeeStorageKey = 'ackee-tracking';
+const motionStorageKey = 'reduce-motion';
+
+describe('SettingsForm', () => {
+ it('renders a form', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('form', { name: /^Settings form/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a theme toggle setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: /^Theme:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a code blocks toggle setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: /^Code blocks:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a motion setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: /^Animations:/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a Ackee setting', () => {
+ render(
+ <SettingsForm
+ ackeeStorageKey={ackeeStorageKey}
+ motionStorageKey={motionStorageKey}
+ />
+ );
+ expect(
+ screen.getByRole('combobox', { name: /^Tracking:/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/forms/settings-form.tsx b/src/components/organisms/forms/settings-form.tsx
new file mode 100644
index 0000000..c897fa5
--- /dev/null
+++ b/src/components/organisms/forms/settings-form.tsx
@@ -0,0 +1,56 @@
+import Form from '@components/atoms/forms/form';
+import AckeeSelect, {
+ type AckeeSelectProps,
+} from '@components/molecules/forms/ackee-select';
+import MotionToggle, {
+ MotionToggleProps,
+} from '@components/molecules/forms/motion-toggle';
+import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
+import ThemeToggle from '@components/molecules/forms/theme-toggle';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './settings-form.module.scss';
+
+export type SettingsFormProps = Pick<AckeeSelectProps, 'tooltipClassName'> & {
+ /**
+ * The local storage key for Ackee settings.
+ */
+ ackeeStorageKey: AckeeSelectProps['storageKey'];
+ /**
+ * The local storage key for Reduce motion settings.
+ */
+ motionStorageKey: MotionToggleProps['storageKey'];
+};
+
+const SettingsForm: FC<SettingsFormProps> = ({
+ ackeeStorageKey,
+ motionStorageKey,
+ tooltipClassName,
+}) => {
+ const intl = useIntl();
+ const ariaLabel = intl.formatMessage({
+ defaultMessage: 'Settings form',
+ id: 'gX+YVy',
+ description: 'SettingsForm: an accessible form name',
+ });
+
+ return (
+ <Form aria-label={ariaLabel} onSubmit={() => null}>
+ <ThemeToggle labelClassName={styles.label} />
+ <PrismThemeToggle labelClassName={styles.label} />
+ <MotionToggle
+ labelClassName={styles.label}
+ storageKey={motionStorageKey}
+ value={false}
+ />
+ <AckeeSelect
+ initialValue="full"
+ labelClassName={styles.label}
+ tooltipClassName={tooltipClassName}
+ storageKey={ackeeStorageKey}
+ />
+ </Form>
+ );
+};
+
+export default SettingsForm;
diff --git a/src/components/organisms/images/gallery.module.scss b/src/components/organisms/images/gallery.module.scss
new file mode 100644
index 0000000..a057ed9
--- /dev/null
+++ b/src/components/organisms/images/gallery.module.scss
@@ -0,0 +1,26 @@
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ @extend %reset-list;
+
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--spacing-sm);
+ max-width: 100%;
+ margin: var(--spacing-sm) 0;
+
+ @for $i from 0 to 6 {
+ &--#{$i}-columns {
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ @include mix.dimensions("sm") {
+ grid-template-columns: repeat(#{$i}, minmax(0, 1fr));
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/images/gallery.stories.tsx b/src/components/organisms/images/gallery.stories.tsx
new file mode 100644
index 0000000..6fc278f
--- /dev/null
+++ b/src/components/organisms/images/gallery.stories.tsx
@@ -0,0 +1,75 @@
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import Gallery from './gallery';
+
+/**
+ * Gallery - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Images/Gallery',
+ component: Gallery,
+ argTypes: {
+ children: {
+ control: {
+ type: null,
+ },
+ description: 'Two or more ResponsiveImage component.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ columns: {
+ control: {
+ type: 'number',
+ min: 2,
+ max: 4,
+ },
+ description: 'The columns count.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof Gallery>;
+
+const image = {
+ alt: 'Modi provident omnis',
+ height: 480,
+ src: 'http://placeimg.com/640/480/fashion',
+ width: 640,
+};
+
+const Template: ComponentStory<typeof Gallery> = (args) => (
+ <Gallery {...args}>
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ </Gallery>
+);
+
+/**
+ * Gallery Stories - Two columns
+ */
+export const TwoColumns = Template.bind({});
+TwoColumns.args = {
+ columns: 2,
+};
+
+/**
+ * Gallery Stories - Three columns
+ */
+export const ThreeColumns = Template.bind({});
+ThreeColumns.args = {
+ columns: 3,
+};
+
+/**
+ * Gallery Stories - Four columns
+ */
+export const FourColumns = Template.bind({});
+FourColumns.args = {
+ columns: 4,
+};
diff --git a/src/components/organisms/images/gallery.test.tsx b/src/components/organisms/images/gallery.test.tsx
new file mode 100644
index 0000000..5f35f0a
--- /dev/null
+++ b/src/components/organisms/images/gallery.test.tsx
@@ -0,0 +1,38 @@
+import ResponsiveImage from '@components/molecules/images/responsive-image';
+import { render, screen } from '@test-utils';
+import Gallery from './gallery';
+
+const columns = 3;
+
+const image = {
+ alt: 'Modi provident omnis',
+ height: 480,
+ src: 'http://placeimg.com/640/480/fashion',
+ width: 640,
+};
+
+describe('Gallery', () => {
+ it('renders the correct number of items', () => {
+ render(
+ <Gallery columns={columns}>
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ </Gallery>
+ );
+ expect(screen.getAllByRole('listitem')).toHaveLength(4);
+ });
+
+ it('renders the right number of columns', () => {
+ render(
+ <Gallery columns={columns}>
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ <ResponsiveImage {...image} />
+ </Gallery>
+ );
+ expect(screen.getByRole('list')).toHaveClass(`wrapper--${columns}-columns`);
+ });
+});
diff --git a/src/components/organisms/images/gallery.tsx b/src/components/organisms/images/gallery.tsx
new file mode 100644
index 0000000..6c4a271
--- /dev/null
+++ b/src/components/organisms/images/gallery.tsx
@@ -0,0 +1,35 @@
+import { type ResponsiveImageProps } from '@components/molecules/images/responsive-image';
+import { Children, FC, ReactElement } from 'react';
+import styles from './gallery.module.scss';
+
+export type GalleryColumn = 2 | 3 | 4;
+
+export type GalleryProps = {
+ /**
+ * The images using ResponsiveImage component.
+ */
+ children: ReactElement<ResponsiveImageProps>[];
+ /**
+ * The columns count.
+ */
+ columns: GalleryColumn;
+};
+
+/**
+ * Gallery component
+ *
+ * Render a gallery of images.
+ */
+const Gallery: FC<GalleryProps> = ({ children, columns }) => {
+ const columnsClass = `wrapper--${columns}-columns`;
+
+ return (
+ <ul className={`${styles.wrapper} ${styles[columnsClass]}`}>
+ {Children.map(children, (child) => {
+ return <li className={styles.item}>{child}</li>;
+ })}
+ </ul>
+ );
+};
+
+export default Gallery;
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;
diff --git a/src/components/organisms/modals/search-modal.module.scss b/src/components/organisms/modals/search-modal.module.scss
new file mode 100644
index 0000000..aba0593
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.module.scss
@@ -0,0 +1,11 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.wrapper {
+ padding-bottom: var(--spacing-md);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ max-width: 40ch;
+ }
+ }
+}
diff --git a/src/components/organisms/modals/search-modal.stories.tsx b/src/components/organisms/modals/search-modal.stories.tsx
new file mode 100644
index 0000000..5a607c6
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.stories.tsx
@@ -0,0 +1,47 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SearchModal from './search-modal';
+
+/**
+ * SearchModal - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Modals',
+ component: SearchModal,
+ args: {
+ searchPage: '#',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the search modal wrapper.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ searchPage: {
+ control: {
+ type: 'text',
+ },
+ description: 'The search results page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SearchModal>;
+
+const Template: ComponentStory<typeof SearchModal> = (args) => (
+ <SearchModal {...args} />
+);
+
+/**
+ * Modals Stories - Search
+ */
+export const Search = Template.bind({});
diff --git a/src/components/organisms/modals/search-modal.test.tsx b/src/components/organisms/modals/search-modal.test.tsx
new file mode 100644
index 0000000..7ba08c0
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.test.tsx
@@ -0,0 +1,9 @@
+import { render, screen } from '@test-utils';
+import SearchModal from './search-modal';
+
+describe('SearchModal', () => {
+ it('renders a search modal', () => {
+ render(<SearchModal searchPage="#" />);
+ expect(screen.getByText('Search')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
new file mode 100644
index 0000000..ed6084a
--- /dev/null
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -0,0 +1,37 @@
+import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
+import { useIntl } from 'react-intl';
+import SearchForm, { type SearchFormProps } from '../forms/search-form';
+import styles from './search-modal.module.scss';
+
+export type SearchModalProps = SearchFormProps & {
+ /**
+ * Set additional classnames to modal wrapper.
+ */
+ className?: ModalProps['className'];
+};
+
+/**
+ * SearchModal
+ *
+ * Render a search form modal.
+ */
+const SearchModal: ForwardRefRenderFunction<
+ HTMLInputElement,
+ SearchModalProps
+> = ({ className, searchPage }, ref) => {
+ const intl = useIntl();
+ const modalTitle = intl.formatMessage({
+ defaultMessage: 'Search',
+ description: 'SearchModal: modal title',
+ id: 'G+Twgm',
+ });
+
+ return (
+ <Modal title={modalTitle} className={`${styles.wrapper} ${className}`}>
+ <SearchForm hideLabel={true} ref={ref} searchPage={searchPage} />
+ </Modal>
+ );
+};
+
+export default forwardRef(SearchModal);
diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss
new file mode 100644
index 0000000..a6a2077
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.module.scss
@@ -0,0 +1,11 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.label {
+ margin-right: auto;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ font-size: var(--font-size-sm);
+ }
+ }
+}
diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx
new file mode 100644
index 0000000..d263e2b
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.stories.tsx
@@ -0,0 +1,67 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SettingsModal from './settings-modal';
+
+/**
+ * SettingsModal - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Modals',
+ component: SettingsModal,
+ argTypes: {
+ ackeeStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'A local storage key for Ackee.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ motionStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'A local storage key for reduced motion setting..',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof SettingsModal>;
+
+const Template: ComponentStory<typeof SettingsModal> = (args) => (
+ <SettingsModal {...args} />
+);
+
+/**
+ * Modals Stories - Settings
+ */
+export const Settings = Template.bind({});
diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx
new file mode 100644
index 0000000..d6ed989
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import SettingsModal from './settings-modal';
+
+describe('SettingsModal', () => {
+ it('renders a fake heading', () => {
+ render(
+ <SettingsModal
+ ackeeStorageKey="ackee-tracking"
+ motionStorageKey="reduce-motion"
+ />
+ );
+ expect(screen.getByText(/Settings/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx
new file mode 100644
index 0000000..5d14836
--- /dev/null
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -0,0 +1,51 @@
+import Spinner from '@components/atoms/loaders/spinner';
+import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import dynamic from 'next/dynamic';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import { type SettingsFormProps } from '../forms/settings-form';
+import styles from './settings-modal.module.scss';
+
+const DynamicSettingsForm = dynamic(
+ () => import('@components/organisms/forms/settings-form'),
+ {
+ loading: () => <Spinner />,
+ ssr: false,
+ }
+);
+
+export type SettingsModalProps = Pick<ModalProps, 'className'> &
+ Pick<
+ SettingsFormProps,
+ 'ackeeStorageKey' | 'motionStorageKey' | 'tooltipClassName'
+ >;
+
+/**
+ * SettingsModal component
+ *
+ * Render a modal with settings options.
+ */
+const SettingsModal: FC<SettingsModalProps> = ({
+ className = '',
+ ...props
+}) => {
+ const intl = useIntl();
+ const title = intl.formatMessage({
+ defaultMessage: 'Settings',
+ description: 'SettingsModal: title',
+ id: 'gPfT/K',
+ });
+
+ return (
+ <Modal
+ title={title}
+ icon="cogs"
+ className={`${styles.wrapper} ${className}`}
+ headingClassName={styles.heading}
+ >
+ <DynamicSettingsForm {...props} />
+ </Modal>
+ );
+};
+
+export default SettingsModal;
diff --git a/src/components/organisms/toolbar/main-nav.module.scss b/src/components/organisms/toolbar/main-nav.module.scss
new file mode 100644
index 0000000..24abc43
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.module.scss
@@ -0,0 +1,96 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.item {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ .checkbox,
+ .label {
+ display: none;
+ }
+
+ .modal {
+ position: relative;
+ }
+ }
+ }
+
+ .modal {
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "md") {
+ padding: var(--spacing-2xs);
+ background: var(--color-bg-secondary);
+ border-top: fun.convert-px(4) solid;
+ border-bottom: fun.convert-px(4) solid;
+ border-image: radial-gradient(
+ ellipse at top,
+ var(--color-primary-lighter) 20%,
+ var(--color-primary) 100%
+ )
+ 1;
+ box-shadow: fun.convert-px(2) fun.convert-px(-2) fun.convert-px(3)
+ fun.convert-px(-1) var(--color-shadow-dark);
+ }
+
+ @include mix.dimensions("sm", "md") {
+ border-left: fun.convert-px(4) solid;
+ border-right: fun.convert-px(4) solid;
+ }
+
+ @include mix.dimensions("md") {
+ top: unset;
+ }
+ }
+ }
+
+ .modal__list {
+ display: flex;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm", "md") {
+ flex-flow: column;
+ }
+ }
+ }
+
+ .checkbox {
+ &:checked {
+ ~ .label .icon {
+ background: transparent;
+ border: transparent;
+
+ &::before {
+ top: 0;
+ transform-origin: 50% 50%;
+ transform: rotate(-45deg);
+ }
+
+ &::after {
+ bottom: 0;
+ transform-origin: 50% 50%;
+ transform: rotate(45deg);
+ }
+ }
+ }
+
+ &:not(:checked) {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ ~ .modal {
+ opacity: 1;
+ visibility: visible;
+ transform: none;
+ }
+ }
+ }
+ }
+ }
+}
+
+.label {
+ display: flex;
+ place-content: center;
+ place-items: center;
+ width: var(--btn-size, #{fun.convert-px(60)});
+ height: var(--btn-size, #{fun.convert-px(60)});
+}
diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx
new file mode 100644
index 0000000..831636f
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.stories.tsx
@@ -0,0 +1,91 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import MainNav from './main-nav';
+
+/**
+ * MainNav - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Toolbar/MainNav',
+ component: MainNav,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the main nav wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Determine if the main nav is open or not.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ items: {
+ description: 'The main nav items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to change main nav state.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MainNav>;
+
+const Template: ComponentStory<typeof MainNav> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return <MainNav isActive={isOpen} setIsActive={setIsOpen} {...args} />;
+};
+
+/**
+ * MainNav Stories - Inactive
+ */
+export const Inactive = Template.bind({});
+Inactive.args = {
+ isActive: false,
+ items: [
+ { id: 'home', label: 'Home', href: '#' },
+ { id: 'contact', label: 'Contact', href: '#' },
+ ],
+};
+
+/**
+ * MainNav Stories - Active
+ */
+export const Active = Template.bind({});
+Active.args = {
+ isActive: true,
+ items: [
+ { id: 'home', label: 'Home', href: '#' },
+ { id: 'contact', label: 'Contact', href: '#' },
+ ],
+};
diff --git a/src/components/organisms/toolbar/main-nav.test.tsx b/src/components/organisms/toolbar/main-nav.test.tsx
new file mode 100644
index 0000000..6e50562
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import MainNav from './main-nav';
+
+const items = [
+ { id: 'home', label: 'Home', href: '/' },
+ { id: 'blog', label: 'Blog', href: '/blog' },
+ { id: 'contact', label: 'Contact', href: '/contact' },
+];
+
+describe('MainNav', () => {
+ it('renders a checkbox to open main nav', () => {
+ render(<MainNav items={items} isActive={false} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open menu');
+ });
+
+ it('renders a checkbox to close main nav', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close menu');
+ });
+
+ it('renders the correct number of items', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('renders some links with the right label', () => {
+ render(<MainNav items={items} isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('link', { name: items[0].label })).toHaveAttribute(
+ 'href',
+ items[0].href
+ );
+ });
+});
diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx
new file mode 100644
index 0000000..d205112
--- /dev/null
+++ b/src/components/organisms/toolbar/main-nav.tsx
@@ -0,0 +1,80 @@
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
+import Label from '@components/atoms/forms/label';
+import Hamburger from '@components/atoms/icons/hamburger';
+import Nav, {
+ type NavProps,
+ type NavItem,
+} from '@components/molecules/nav/nav';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
+import { useIntl } from 'react-intl';
+import mainNavStyles from './main-nav.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
+
+export type MainNavProps = {
+ /**
+ * Set additional classnames to the nav element.
+ */
+ className?: NavProps['className'];
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * The main nav items.
+ */
+ items: NavItem[];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+};
+
+/**
+ * MainNav component
+ *
+ * Render the main navigation.
+ */
+const MainNav: ForwardRefRenderFunction<HTMLDivElement, MainNavProps> = (
+ { className = '', isActive, items, setIsActive },
+ ref
+) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close menu',
+ description: 'MainNav: Close label',
+ id: 'aJC7D2',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open menu',
+ description: 'MainNav: Open label',
+ id: 'GTbGMy',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${mainNavStyles.item}`} ref={ref}>
+ <Checkbox
+ id="main-nav-button"
+ name="main-nav-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${mainNavStyles.checkbox}`}
+ />
+ <Label
+ htmlFor="main-nav-button"
+ aria-label={label}
+ className={`${sharedStyles.label} ${mainNavStyles.label}`}
+ >
+ <Hamburger iconClassName={mainNavStyles.icon} />
+ </Label>
+ <Nav
+ kind="main"
+ items={items}
+ className={`${sharedStyles.modal} ${mainNavStyles.modal} ${className}`}
+ listClassName={mainNavStyles.modal__list}
+ />
+ </div>
+ );
+};
+
+export default forwardRef(MainNav);
diff --git a/src/components/organisms/toolbar/search.module.scss b/src/components/organisms/toolbar/search.module.scss
new file mode 100644
index 0000000..c310594
--- /dev/null
+++ b/src/components/organisms/toolbar/search.module.scss
@@ -0,0 +1,3 @@
+.modal {
+ padding-bottom: var(--spacing-md);
+}
diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx
new file mode 100644
index 0000000..f0f65b4
--- /dev/null
+++ b/src/components/organisms/toolbar/search.stories.tsx
@@ -0,0 +1,88 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import Search from './search';
+
+/**
+ * Search - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Toolbar/Search',
+ component: Search,
+ args: {
+ searchPage: '#',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Define the modal state: either opened or closed.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ searchPage: {
+ control: {
+ type: 'text',
+ },
+ description: 'The search results page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to update modal state.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof Search>;
+
+const Template: ComponentStory<typeof Search> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return <Search isActive={isOpen} setIsActive={setIsOpen} {...args} />;
+};
+
+/**
+ * Search Stories - Inactive
+ */
+export const Inactive = Template.bind({});
+Inactive.args = {
+ isActive: false,
+};
+
+/**
+ * Search Stories - Active
+ */
+export const Active = Template.bind({});
+Active.args = {
+ isActive: true,
+};
diff --git a/src/components/organisms/toolbar/search.test.tsx b/src/components/organisms/toolbar/search.test.tsx
new file mode 100644
index 0000000..7c77eac
--- /dev/null
+++ b/src/components/organisms/toolbar/search.test.tsx
@@ -0,0 +1,14 @@
+import { render, screen } from '@test-utils';
+import Search from './search';
+
+describe('Search', () => {
+ it('renders a button to open search modal', () => {
+ render(<Search searchPage="#" isActive={false} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open search');
+ });
+
+ it('renders a button to close search modal', () => {
+ render(<Search searchPage="#" isActive={true} setIsActive={() => null} />);
+ expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close search');
+ });
+});
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
new file mode 100644
index 0000000..6a8af26
--- /dev/null
+++ b/src/components/organisms/toolbar/search.tsx
@@ -0,0 +1,80 @@
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
+import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
+import FlippingLabel from '@components/molecules/forms/flipping-label';
+import useInputAutofocus from '@utils/hooks/use-input-autofocus';
+import { forwardRef, ForwardRefRenderFunction, useRef } from 'react';
+import { useIntl } from 'react-intl';
+import SearchModal, { type SearchModalProps } from '../modals/search-modal';
+import searchStyles from './search.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
+
+export type SearchProps = {
+ /**
+ * Set additional classnames to the modal wrapper.
+ */
+ className?: SearchModalProps['className'];
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * A callback function to execute search.
+ */
+ searchPage: SearchModalProps['searchPage'];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+};
+
+const Search: ForwardRefRenderFunction<HTMLDivElement, SearchProps> = (
+ { className = '', isActive, searchPage, setIsActive },
+ ref
+) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close search',
+ id: 'LDDUNO',
+ description: 'Search: Close label',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open search',
+ id: 'Xj+WXB',
+ description: 'Search: Open label',
+ });
+
+ const searchInputRef = useRef<HTMLInputElement>(null);
+ useInputAutofocus({
+ condition: isActive,
+ delay: 360,
+ ref: searchInputRef,
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${searchStyles.item}`} ref={ref}>
+ <Checkbox
+ id="search-button"
+ name="search-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${searchStyles.checkbox}`}
+ />
+ <FlippingLabel
+ className={sharedStyles.label}
+ htmlFor="search-button"
+ aria-label={label}
+ isActive={isActive}
+ >
+ <MagnifyingGlass />
+ </FlippingLabel>
+ <SearchModal
+ className={`${sharedStyles.modal} ${searchStyles.modal} ${className}`}
+ ref={searchInputRef}
+ searchPage={searchPage}
+ />
+ </div>
+ );
+};
+
+export default forwardRef(Search);
diff --git a/src/components/organisms/toolbar/settings.module.scss b/src/components/organisms/toolbar/settings.module.scss
new file mode 100644
index 0000000..08c8cd4
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.modal {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ width: 120%;
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx
new file mode 100644
index 0000000..08ec579
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.stories.tsx
@@ -0,0 +1,112 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { useState } from 'react';
+import Settings from './settings';
+
+/**
+ * Settings - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Toolbar/Settings',
+ component: Settings,
+ args: {
+ ackeeStorageKey: 'ackee-tracking',
+ motionStorageKey: 'reduced-motion',
+ },
+ argTypes: {
+ ackeeStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set Ackee settings local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the modal wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ isActive: {
+ control: {
+ type: null,
+ },
+ description: 'Define the modal state: either opened or closed.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ motionStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set Reduced motion settings local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ setIsActive: {
+ control: {
+ type: null,
+ },
+ description: 'A callback function to update modal state.',
+ type: {
+ name: 'function',
+ required: true,
+ },
+ },
+ tooltipClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the tooltip wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof Settings>;
+
+const Template: ComponentStory<typeof Settings> = ({
+ isActive,
+ setIsActive: _setIsActive,
+ ...args
+}) => {
+ const [isOpen, setIsOpen] = useState<boolean>(isActive);
+
+ return <Settings isActive={isOpen} setIsActive={setIsOpen} {...args} />;
+};
+
+/**
+ * Settings Stories - Inactive
+ */
+export const Inactive = Template.bind({});
+Inactive.args = {
+ isActive: false,
+};
+
+/**
+ * Settings Stories - Active
+ */
+export const Active = Template.bind({});
+Active.args = {
+ isActive: true,
+};
diff --git a/src/components/organisms/toolbar/settings.test.tsx b/src/components/organisms/toolbar/settings.test.tsx
new file mode 100644
index 0000000..7ccb234
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import Settings from './settings';
+
+describe('Settings', () => {
+ it('renders a button to open settings modal', () => {
+ render(
+ <Settings
+ ackeeStorageKey="ackee-tracking"
+ motionStorageKey="reduced-motion"
+ isActive={false}
+ setIsActive={() => null}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: 'Open settings' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders a button to close settings modal', () => {
+ render(
+ <Settings
+ ackeeStorageKey="ackee-tracking"
+ motionStorageKey="reduced-motion"
+ isActive={true}
+ setIsActive={() => null}
+ />
+ );
+ expect(
+ screen.getByRole('checkbox', { name: 'Close settings' })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
new file mode 100644
index 0000000..ceb6db4
--- /dev/null
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -0,0 +1,74 @@
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
+import Cog from '@components/atoms/icons/cog';
+import FlippingLabel from '@components/molecules/forms/flipping-label';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
+import { useIntl } from 'react-intl';
+import SettingsModal, {
+ type SettingsModalProps,
+} from '../modals/settings-modal';
+import settingsStyles from './settings.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
+
+export type SettingsProps = SettingsModalProps & {
+ /**
+ * The button state.
+ */
+ isActive: CheckboxProps['value'];
+ /**
+ * A callback function to handle button state.
+ */
+ setIsActive: CheckboxProps['setValue'];
+};
+
+const Settings: ForwardRefRenderFunction<HTMLDivElement, SettingsProps> = (
+ {
+ ackeeStorageKey,
+ className = '',
+ isActive,
+ motionStorageKey,
+ setIsActive,
+ tooltipClassName = '',
+ },
+ ref
+) => {
+ const intl = useIntl();
+ const label = isActive
+ ? intl.formatMessage({
+ defaultMessage: 'Close settings',
+ id: '+viX9b',
+ description: 'Settings: Close label',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'Open settings',
+ id: 'QCW3cy',
+ description: 'Settings: Open label',
+ });
+
+ return (
+ <div className={`${sharedStyles.item} ${settingsStyles.item}`} ref={ref}>
+ <Checkbox
+ id="settings-button"
+ name="settings-button"
+ value={isActive}
+ setValue={setIsActive}
+ className={`${sharedStyles.checkbox} ${settingsStyles.checkbox}`}
+ />
+ <FlippingLabel
+ className={sharedStyles.label}
+ htmlFor="settings-button"
+ aria-label={label}
+ isActive={isActive}
+ >
+ <Cog />
+ </FlippingLabel>
+ <SettingsModal
+ ackeeStorageKey={ackeeStorageKey}
+ className={`${sharedStyles.modal} ${settingsStyles.modal} ${className}`}
+ motionStorageKey={motionStorageKey}
+ tooltipClassName={tooltipClassName}
+ />
+ </div>
+ );
+};
+
+export default forwardRef(Settings);
diff --git a/src/components/organisms/toolbar/toolbar-items.module.scss b/src/components/organisms/toolbar/toolbar-items.module.scss
new file mode 100644
index 0000000..86b4924
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar-items.module.scss
@@ -0,0 +1,91 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.item {
+ --btn-size: #{fun.convert-px(65)};
+
+ display: flex;
+ position: relative;
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ justify-content: flex-end;
+ }
+
+ @include mix.dimensions("md") {
+ justify-content: flex-end;
+ }
+ }
+}
+
+.modal {
+ position: absolute;
+ top: var(--toolbar-size, calc(var(--btn-size) + var(--spacing-2xs)));
+ transition: all 0.8s ease-in-out 0s, background 0s;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ position: fixed;
+ left: 0;
+ right: 0;
+ }
+ }
+}
+
+.label {
+ --draw-border-thickness: #{fun.convert-px(4)};
+ --draw-border-color1: var(--color-primary-light);
+ --draw-border-color2: var(--color-primary-lighter);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ border-radius: fun.convert-px(5);
+ }
+ }
+
+ &:hover {
+ @extend %draw-borders;
+ }
+
+ &:active {
+ --draw-border-color1: var(--color-primary-dark);
+ --draw-border-color2: var(--color-primary-light);
+
+ @extend %draw-borders;
+ }
+}
+
+.checkbox {
+ position: absolute;
+ top: calc(var(--btn-size) / 2);
+ left: calc(var(--btn-size) / 2);
+ opacity: 0;
+ cursor: pointer;
+
+ &:hover,
+ &:focus {
+ ~ .label {
+ @extend %draw-borders;
+ }
+ }
+
+ &:not(:checked) {
+ ~ .modal {
+ opacity: 0;
+ visibility: hidden;
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "sm") {
+ transform: translateX(-100vw);
+ }
+
+ @include mix.dimensions("sm") {
+ transform: perspective(#{fun.convert-px(400)})
+ translate3d(0, 0, #{fun.convert-px(-400)});
+ transform-origin: 100% -50%;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/toolbar.module.scss b/src/components/organisms/toolbar/toolbar.module.scss
new file mode 100644
index 0000000..4bcabcb
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.module.scss
@@ -0,0 +1,98 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.wrapper {
+ --toolbar-size: #{fun.convert-px(75)};
+
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: space-around;
+ width: 100%;
+ height: var(--toolbar-size);
+ position: relative;
+ 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);
+
+ :global {
+ animation: slide-in-from-bottom 0.8s ease-in-out 0s 1;
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ :global {
+ animation: slide-in-from-top 1s ease-in-out 0s 1;
+ }
+ }
+ }
+
+ .modal {
+ &--search,
+ &--settings {
+ @include mix.media("screen") {
+ @include mix.dimensions("sm") {
+ min-width: 35ch;
+ }
+ }
+ }
+ }
+
+ @include mix.media("screen") {
+ @include mix.dimensions(null, "2xs", "height") {
+ --toolbar-size: #{fun.convert-px(70)};
+ }
+
+ @include mix.dimensions(null, "sm") {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ z-index: 5;
+
+ .modal {
+ top: unset;
+ bottom: calc(var(--toolbar-size) - #{fun.convert-px(4)});
+ max-height: calc(100vh - var(--toolbar-size));
+ }
+
+ .tooltip {
+ padding: calc(var(--title-height) / 2 + var(--spacing-2xs))
+ var(--spacing-2xs) var(--spacing-2xs);
+ top: unset;
+ bottom: calc(100% + var(--spacing-2xs));
+ transform-origin: bottom right;
+ }
+ }
+
+ @include mix.dimensions("sm", "md") {
+ .modal {
+ top: calc(var(--toolbar-size) + var(--spacing-2xs));
+ bottom: unset;
+ }
+ }
+
+ @include mix.dimensions("sm") {
+ justify-content: flex-end;
+ gap: var(--spacing-sm);
+
+ .tooltip {
+ transform-origin: top right;
+ }
+ }
+
+ @include mix.dimensions("md") {
+ .tooltip {
+ width: 120%;
+ right: -10%;
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx
new file mode 100644
index 0000000..d613442
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.stories.tsx
@@ -0,0 +1,90 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ToolbarComponent from './toolbar';
+
+/**
+ * Toolbar - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Toolbar',
+ component: ToolbarComponent,
+ args: {
+ ackeeStorageKey: 'ackee-tracking',
+ motionStorageKey: 'reduced-motion',
+ searchPage: '#',
+ },
+ argTypes: {
+ ackeeStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set Ackee settings local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the toolbar wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ motionStorageKey: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set Reduced motion settings local storage key.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ nav: {
+ description: 'The main nav items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ searchPage: {
+ control: {
+ type: 'text',
+ },
+ description: 'The search results page url.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof ToolbarComponent>;
+
+const Template: ComponentStory<typeof ToolbarComponent> = (args) => (
+ <ToolbarComponent {...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' },
+];
+
+/**
+ * Toolbar Story
+ */
+export const Toolbar = Template.bind({});
+Toolbar.args = {
+ nav,
+};
diff --git a/src/components/organisms/toolbar/toolbar.test.tsx b/src/components/organisms/toolbar/toolbar.test.tsx
new file mode 100644
index 0000000..72965e8
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@test-utils';
+import Toolbar from './toolbar';
+
+const nav = [
+ { id: 'home-link', href: '/', label: 'Home' },
+ { id: 'blog-link', href: '/blog', label: 'Blog' },
+ { id: 'cv-link', href: '/cv', label: 'CV' },
+ { id: 'contact-link', href: '/contact', label: 'Contact' },
+];
+
+describe('Toolbar', () => {
+ it('renders a navigation menu', () => {
+ render(
+ <Toolbar
+ ackeeStorageKey="ackee-tracking"
+ motionStorageKey="reduced-motion"
+ nav={nav}
+ searchPage="#"
+ />
+ );
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
new file mode 100644
index 0000000..ee61a7b
--- /dev/null
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -0,0 +1,77 @@
+import useClickOutside from '@utils/hooks/use-click-outside';
+import useRouteChange from '@utils/hooks/use-route-change';
+import { FC, useRef, useState } from 'react';
+import MainNav, { type MainNavProps } from '../toolbar/main-nav';
+import Search, { type SearchProps } from '../toolbar/search';
+import Settings, { SettingsProps } from '../toolbar/settings';
+import styles from './toolbar.module.scss';
+
+export type ToolbarProps = Pick<SearchProps, 'searchPage'> &
+ Pick<SettingsProps, 'ackeeStorageKey' | 'motionStorageKey'> & {
+ /**
+ * Set additional classnames to the toolbar wrapper.
+ */
+ className?: string;
+ /**
+ * The main nav items.
+ */
+ nav: MainNavProps['items'];
+ };
+
+/**
+ * Toolbar component
+ *
+ * Render the website toolbar.
+ */
+const Toolbar: FC<ToolbarProps> = ({
+ ackeeStorageKey,
+ className = '',
+ motionStorageKey,
+ nav,
+ searchPage,
+}) => {
+ const [isNavOpened, setIsNavOpened] = useState<boolean>(false);
+ const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false);
+ const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
+ const mainNavRef = useRef<HTMLDivElement>(null);
+ const searchRef = useRef<HTMLDivElement>(null);
+ const settingsRef = useRef<HTMLDivElement>(null);
+
+ useClickOutside(mainNavRef, () => isNavOpened && setIsNavOpened(false));
+ useClickOutside(searchRef, () => isSearchOpened && setIsSearchOpened(false));
+ useClickOutside(
+ settingsRef,
+ () => isSettingsOpened && setIsSettingsOpened(false)
+ );
+ useRouteChange(() => setIsSearchOpened(false));
+
+ return (
+ <div className={`${styles.wrapper} ${className}`}>
+ <MainNav
+ items={nav}
+ isActive={isNavOpened}
+ setIsActive={setIsNavOpened}
+ className={styles.modal}
+ ref={mainNavRef}
+ />
+ <Search
+ searchPage={searchPage}
+ isActive={isSearchOpened}
+ setIsActive={setIsSearchOpened}
+ className={`${styles.modal} ${styles['modal--search']}`}
+ ref={searchRef}
+ />
+ <Settings
+ ackeeStorageKey={ackeeStorageKey}
+ className={`${styles.modal} ${styles['modal--settings']}`}
+ isActive={isSettingsOpened}
+ motionStorageKey={motionStorageKey}
+ ref={settingsRef}
+ setIsActive={setIsSettingsOpened}
+ tooltipClassName={styles.tooltip}
+ />
+ </div>
+ );
+};
+
+export default Toolbar;
diff --git a/src/components/organisms/widgets/image-widget.module.scss b/src/components/organisms/widgets/image-widget.module.scss
new file mode 100644
index 0000000..0d69441
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.module.scss
@@ -0,0 +1,47 @@
+@use "@styles/abstracts/functions" as fun;
+
+.figure {
+ --scale-up: 1.02;
+ --scale-down: 0.98;
+
+ margin: 0;
+ padding: fun.convert-px(5);
+ border: fun.convert-px(1) solid var(--color-border);
+}
+
+.txt {
+ padding: var(--spacing-sm);
+}
+
+.widget {
+ &--left {
+ .figure {
+ margin-right: auto;
+ }
+
+ .txt {
+ text-align: left;
+ }
+ }
+
+ &--center {
+ .figure {
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .txt {
+ text-align: center;
+ }
+ }
+
+ &--right {
+ .figure {
+ margin-left: auto;
+ }
+
+ .txt {
+ text-align: right;
+ }
+ }
+}
diff --git a/src/components/organisms/widgets/image-widget.stories.tsx b/src/components/organisms/widgets/image-widget.stories.tsx
new file mode 100644
index 0000000..2271c03
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.stories.tsx
@@ -0,0 +1,181 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ImageWidget from './image-widget';
+
+/**
+ * ImageWidget - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Widgets/Image',
+ component: ImageWidget,
+ args: {
+ alignment: 'left',
+ },
+ argTypes: {
+ alignment: {
+ control: {
+ type: 'select',
+ },
+ description: 'The content alignment.',
+ options: ['left', 'center', 'right'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'left' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the widget wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ description: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add a caption image.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ expanded: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'The state of the widget.',
+ type: {
+ name: 'boolean',
+ required: true,
+ },
+ },
+ image: {
+ description: 'An image object.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ imageClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the image wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'The widget title level (hn).',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ url: {
+ control: {
+ type: 'text',
+ },
+ description: 'Add a link to the image.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof ImageWidget>;
+
+const Template: ComponentStory<typeof ImageWidget> = (args) => (
+ <ImageWidget {...args} />
+);
+
+const image = {
+ alt: 'Et perferendis quaerat',
+ height: 480,
+ src: 'http://placeimg.com/640/480/nature',
+ width: 640,
+};
+
+/**
+ * ImageWidget Stories - Align left
+ */
+export const AlignLeft = Template.bind({});
+AlignLeft.args = {
+ alignment: 'left',
+ expanded: true,
+ image,
+ level: 2,
+ title: 'Quo et totam',
+};
+
+/**
+ * ImageWidget Stories - Align center
+ */
+export const AlignCenter = Template.bind({});
+AlignCenter.args = {
+ alignment: 'center',
+ expanded: true,
+ image,
+ level: 2,
+ title: 'Quo et totam',
+};
+
+/**
+ * ImageWidget Stories - Align right
+ */
+export const AlignRight = Template.bind({});
+AlignRight.args = {
+ alignment: 'right',
+ expanded: true,
+ image,
+ level: 2,
+ title: 'Quo et totam',
+};
+
+/**
+ * ImageWidget Stories - With description
+ */
+export const WithDescription = Template.bind({});
+WithDescription.args = {
+ description: 'Sint enim harum',
+ expanded: true,
+ image,
+ level: 2,
+ title: 'Quo et totam',
+};
diff --git a/src/components/organisms/widgets/image-widget.test.tsx b/src/components/organisms/widgets/image-widget.test.tsx
new file mode 100644
index 0000000..c6b1a3a
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from '@test-utils';
+import ImageWidget from './image-widget';
+
+const description = 'Ut vitae sit';
+
+const img = {
+ alt: 'Et perferendis quaerat',
+ height: 480,
+ src: 'http://placeimg.com/640/480/nature',
+ width: 640,
+};
+
+const title = 'Fugiat cumque et';
+const titleLevel = 2;
+
+const url = '/another-page';
+
+describe('ImageWidget', () => {
+ it('renders an image', () => {
+ render(
+ <ImageWidget
+ expanded={true}
+ image={img}
+ title={title}
+ level={titleLevel}
+ />
+ );
+ expect(screen.getByRole('img', { name: img.alt })).toBeInTheDocument();
+ });
+
+ it('renders a link', () => {
+ render(
+ <ImageWidget
+ expanded={true}
+ image={img}
+ title={title}
+ level={titleLevel}
+ url={url}
+ />
+ );
+ expect(screen.getByRole('link', { name: img.alt })).toHaveAttribute(
+ 'href',
+ url
+ );
+ });
+
+ it('renders a description', () => {
+ render(
+ <ImageWidget
+ expanded={true}
+ image={img}
+ description={description}
+ title={title}
+ level={titleLevel}
+ />
+ );
+ expect(screen.getByText(description)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/widgets/image-widget.tsx b/src/components/organisms/widgets/image-widget.tsx
new file mode 100644
index 0000000..873337b
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.tsx
@@ -0,0 +1,69 @@
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { FC } from 'react';
+import styles from './image-widget.module.scss';
+
+export type Alignment = 'left' | 'center' | 'right';
+
+export type Image = Pick<
+ ResponsiveImageProps,
+ 'alt' | 'height' | 'src' | 'width'
+>;
+
+export type ImageWidgetProps = Pick<
+ WidgetProps,
+ 'className' | 'expanded' | 'level' | 'title'
+> & {
+ /**
+ * The content alignment.
+ */
+ alignment?: Alignment;
+ /**
+ * Add a caption to the image.
+ */
+ description?: ResponsiveImageProps['caption'];
+ /**
+ * An object describing the image.
+ */
+ image: Image;
+ /**
+ * Set additional classnames to the image wrapper.
+ */
+ imageClassName?: string;
+ /**
+ * Add a link to the image.
+ */
+ url?: ResponsiveImageProps['target'];
+};
+
+/**
+ * ImageWidget component
+ *
+ * Renders a widget that print an image and an optional text.
+ */
+const ImageWidget: FC<ImageWidgetProps> = ({
+ alignment = 'left',
+ className = '',
+ description,
+ image,
+ imageClassName = '',
+ url,
+ ...props
+}) => {
+ const alignmentClass = `widget--${alignment}`;
+
+ return (
+ <Widget className={`${styles[alignmentClass]} ${className}`} {...props}>
+ <ResponsiveImage
+ target={url}
+ caption={description}
+ className={`${styles.figure} ${imageClassName}`}
+ {...image}
+ />
+ </Widget>
+ );
+};
+
+export default ImageWidget;
diff --git a/src/components/organisms/widgets/links-list-widget.module.scss b/src/components/organisms/widgets/links-list-widget.module.scss
new file mode 100644
index 0000000..4444df4
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.module.scss
@@ -0,0 +1,75 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/placeholders";
+
+.widget {
+ .list {
+ .list {
+ > *:first-child {
+ border-top: fun.convert-px(1) solid var(--color-primary);
+ }
+ }
+
+ &__link {
+ display: block;
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ background: none;
+ text-decoration: underline solid transparent 0;
+
+ &:hover,
+ &:focus {
+ background: var(--color-bg-secondary);
+ font-weight: 600;
+ }
+
+ &:focus {
+ color: var(--color-primary);
+ text-decoration-color: var(--color-primary-light);
+ text-decoration-thickness: 0.25ex;
+ }
+
+ &:active {
+ background: var(--color-bg-tertiary);
+ text-decoration-color: transparent;
+ text-decoration-thickness: 0;
+ }
+ }
+
+ &--ordered {
+ @extend %reset-ordered-list;
+
+ counter-reset: link;
+
+ .list__link {
+ counter-increment: link;
+
+ &::before {
+ padding-right: var(--spacing-2xs);
+ content: counters(link, ".") ". ";
+ color: var(--color-secondary);
+ }
+ }
+ }
+
+ &--unordered {
+ @extend %reset-list;
+ }
+
+ &__item {
+ &:not(:last-child) {
+ border-bottom: fun.convert-px(1) solid var(--color-primary);
+ }
+
+ > .list {
+ .list__link {
+ padding-left: var(--spacing-md);
+ }
+
+ .list__item > .list {
+ .list__link {
+ padding-left: var(--spacing-xl);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/organisms/widgets/links-list-widget.stories.tsx b/src/components/organisms/widgets/links-list-widget.stories.tsx
new file mode 100644
index 0000000..cdfa96a
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.stories.tsx
@@ -0,0 +1,122 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LinksListWidget from './links-list-widget';
+
+/**
+ * LinksListWidget - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Widgets/LinksList',
+ component: LinksListWidget,
+ args: {
+ kind: 'unordered',
+ },
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ items: {
+ description: 'The widget data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ kind: {
+ control: {
+ type: 'select',
+ },
+ description: 'The list kind: either ordered or unordered.',
+ options: ['ordered', 'unordered'],
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 'unordered' },
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof LinksListWidget>;
+
+const Template: ComponentStory<typeof LinksListWidget> = (args) => (
+ <LinksListWidget {...args} />
+);
+
+const items = [
+ { name: 'Level 1: Item 1', url: '#' },
+ {
+ name: 'Level 1: Item 2',
+ url: '#',
+ child: [
+ { name: 'Level 2: Item 1', url: '#' },
+ { name: 'Level 2: Item 2', url: '#' },
+ {
+ name: 'Level 2: Item 3',
+ url: '#',
+ child: [
+ { name: 'Level 3: Item 1', url: '#' },
+ { name: 'Level 3: Item 2', url: '#' },
+ ],
+ },
+ { name: 'Level 2: Item 4', url: '#' },
+ ],
+ },
+ { name: 'Level 1: Item 3', url: '#' },
+ { name: 'Level 1: Item 4', url: '#' },
+];
+
+/**
+ * Links List Widget Stories - Unordered
+ */
+export const Unordered = Template.bind({});
+Unordered.args = {
+ items,
+ kind: 'unordered',
+ level: 2,
+ title: 'A list of links',
+};
+
+/**
+ * Links List Widget Stories - Ordered
+ */
+export const Ordered = Template.bind({});
+Ordered.args = {
+ items,
+ kind: 'ordered',
+ level: 2,
+ title: 'A list of links',
+};
diff --git a/src/components/organisms/widgets/links-list-widget.test.tsx b/src/components/organisms/widgets/links-list-widget.test.tsx
new file mode 100644
index 0000000..a8d6a35
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@test-utils';
+import LinksListWidget from './links-list-widget';
+
+const title = 'Voluptatem minus autem';
+
+const items = [
+ { name: 'Item 1', url: '/item-1' },
+ { name: 'Item 2', url: '/item-2' },
+ { name: 'Item 3', url: '/item-3' },
+];
+
+describe('LinksListWidget', () => {
+ it('renders a widget title', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(
+ screen.getByRole('heading', { level: 2, name: new RegExp(title, 'i') })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the correct number of items', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('renders some links', () => {
+ render(<LinksListWidget items={items} title={title} level={2} />);
+ expect(screen.getByRole('link', { name: items[0].name })).toHaveAttribute(
+ 'href',
+ items[0].url
+ );
+ });
+});
diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
new file mode 100644
index 0000000..a9c677b
--- /dev/null
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -0,0 +1,85 @@
+import Link from '@components/atoms/links/link';
+import List, {
+ type ListProps,
+ type ListItem,
+} from '@components/atoms/lists/list';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { slugify } from '@utils/helpers/strings';
+import { FC } from 'react';
+import styles from './links-list-widget.module.scss';
+
+export type LinksListItems = {
+ /**
+ * An array of name/url couple child of this list item.
+ */
+ child?: LinksListItems[];
+ /**
+ * The item name.
+ */
+ name: string;
+ /**
+ * The item url.
+ */
+ url: string;
+};
+
+export type LinksListWidgetProps = Pick<WidgetProps, 'level' | 'title'> &
+ Pick<ListProps, 'className' | 'kind'> & {
+ /**
+ * An array of name/url couple.
+ */
+ items: LinksListItems[];
+ };
+
+/**
+ * LinksListWidget component
+ *
+ * Render a list of links inside a widget.
+ */
+const LinksListWidget: FC<LinksListWidgetProps> = ({
+ className = '',
+ items,
+ kind = 'unordered',
+ ...props
+}) => {
+ const listKindClass = `list--${kind}`;
+
+ /**
+ * Format the widget data to be used as List items.
+ *
+ * @param {LinksListItems[]} data - The widget data.
+ * @returns {ListItem[]} The list items data.
+ */
+ const getListItems = (data: LinksListItems[]): ListItem[] => {
+ return data.map((item) => {
+ return {
+ id: slugify(item.name),
+ child: item.child && getListItems(item.child),
+ value: (
+ <Link href={item.url} className={styles.list__link}>
+ {item.name}
+ </Link>
+ ),
+ };
+ });
+ };
+
+ return (
+ <Widget
+ expanded={true}
+ withBorders={true}
+ className={styles.widget}
+ withScroll={true}
+ {...props}
+ >
+ <List
+ items={getListItems(items)}
+ kind={kind}
+ className={`${styles.list} ${styles[listKindClass]} ${className}`}
+ itemsClassName={styles.list__item}
+ />
+ </Widget>
+ );
+};
+
+export default LinksListWidget;
diff --git a/src/components/organisms/widgets/sharing.module.scss b/src/components/organisms/widgets/sharing.module.scss
new file mode 100644
index 0000000..e06d4e3
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-xs);
+ margin: 0;
+ padding: 0 var(--spacing-2xs);
+ list-style-type: none;
+}
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
new file mode 100644
index 0000000..59b86d3
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -0,0 +1,91 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SharingWidget from './sharing';
+
+/**
+ * Sharing - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Widgets',
+ component: SharingWidget,
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the sharing links list.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ data: {
+ description: 'The page data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ expanded: {
+ control: {
+ type: null,
+ },
+ description: 'Default widget state (expanded or collapsed).',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: true },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ level: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'The heading level.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: 2 },
+ },
+ type: {
+ name: 'number',
+ required: false,
+ },
+ },
+ media: {
+ control: {
+ type: null,
+ },
+ description: 'An array of active and ordered sharing medium.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SharingWidget>;
+
+const Template: ComponentStory<typeof SharingWidget> = (args) => (
+ <SharingWidget {...args} />
+);
+
+/**
+ * Widgets Stories - Sharing
+ */
+export const Sharing = Template.bind({});
+Sharing.args = {
+ data: {
+ excerpt:
+ 'Alias similique eius ducimus laudantium aspernatur. Est rem ut eum temporibus sit reprehenderit aut non molestias. Vel dolorem expedita labore quo inventore aliquid nihil nam. Possimus nobis enim quas corporis eos.',
+ title: 'Accusantium totam nostrum',
+ url: 'https://www.example.test',
+ },
+ media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'],
+};
diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx
new file mode 100644
index 0000000..48da49e
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@test-utils';
+import Sharing, { type SharingData } from './sharing';
+
+const postData: SharingData = {
+ excerpt: 'A post excerpt',
+ title: 'A post title',
+ url: 'https://sharing-website.test',
+};
+
+describe('Sharing', () => {
+ it('renders a sharing widget', () => {
+ render(<Sharing data={postData} media={['facebook', 'twitter']} />);
+ expect(
+ screen.getByRole('link', { name: 'Share on facebook' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('link', { name: 'Share on twitter' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('link', { name: 'Share on linkedin' })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
new file mode 100644
index 0000000..c63f5db
--- /dev/null
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -0,0 +1,214 @@
+import SharingLink, {
+ type SharingMedium,
+} from '@components/atoms/links/sharing-link';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './sharing.module.scss';
+
+export type SharingData = {
+ /**
+ * The content excerpt.
+ */
+ excerpt: string;
+ /**
+ * The content title.
+ */
+ title: string;
+ /**
+ * The content url.
+ */
+ url: string;
+};
+
+export type SharingProps = {
+ /**
+ * Set additional classnames to the sharing links list.
+ */
+ className?: string;
+ /**
+ * The page data to share.
+ */
+ data: SharingData;
+ /**
+ * The widget default state.
+ */
+ expanded?: WidgetProps['expanded'];
+ /**
+ * The HTML heading level.
+ */
+ level?: WidgetProps['level'];
+ /**
+ * A list of active and ordered sharing medium.
+ */
+ media: SharingMedium[];
+};
+
+/**
+ * Sharing widget component
+ *
+ * Render a list of sharing links inside a widget.
+ */
+const Sharing: FC<SharingProps> = ({
+ className = '',
+ data,
+ media,
+ expanded = true,
+ level = 2,
+ ...props
+}) => {
+ const intl = useIntl();
+ const widgetTitle = intl.formatMessage({
+ defaultMessage: 'Share',
+ id: 'q3U6uI',
+ description: 'Sharing: widget title',
+ });
+
+ /**
+ * Build the Diaspora sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Diaspora url.
+ */
+ const buildDiasporaUrl = (title: string, url: string): string => {
+ const titleParam = `title=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://share.diasporafoundation.org/?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Build the mailto url from provided data.
+ *
+ * @param {string} excerpt - The content excerpt.
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The mailto url with params.
+ */
+ const buildEmailUrl = (
+ excerpt: string,
+ title: string,
+ url: string
+ ): string => {
+ const intro = intl.formatMessage({
+ defaultMessage: 'Introduction:',
+ description: 'Sharing: email content prefix',
+ id: 'yfgMcl',
+ });
+ const readMore = intl.formatMessage({
+ defaultMessage: 'Read more here:',
+ description: 'Sharing: content link prefix',
+ id: 'UsQske',
+ });
+ const body = `${intro}\n\n"${excerpt}"\n\n${readMore} ${url}`;
+ const bodyParam = excerpt ? `body=${encodeURI(body)}` : '';
+
+ const subject = intl.formatMessage(
+ {
+ defaultMessage: 'You should read {title}',
+ description: 'Sharing: subject text',
+ id: 'azgQuH',
+ },
+ { title }
+ );
+ const subjectParam = `subject=${encodeURI(subject)}`;
+
+ return `mailto:?${bodyParam}&${subjectParam}`;
+ };
+
+ /**
+ * Build the Facebook sharing url with provided data.
+ *
+ * @param {string} url - The content url.
+ * @returns {string} The Facebook url.
+ */
+ const buildFacebookUrl = (url: string): string => {
+ const urlParam = `u=${encodeURI(url)}`;
+ return `https://www.facebook.com/sharer/sharer.php?${urlParam}`;
+ };
+
+ /**
+ * Build the Journal du Hacker sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Journal du Hacker url.
+ */
+ const buildJdHUrl = (title: string, url: string): string => {
+ const titleParam = `title=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://www.journalduhacker.net/stories/new?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Build the LinkedIn sharing url with provided data.
+ *
+ * @param {string} url - The content url.
+ * @returns {string} The LinkedIn url.
+ */
+ const buildLinkedInUrl = (url: string): string => {
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://www.linkedin.com/sharing/share-offsite/?${urlParam}`;
+ };
+
+ /**
+ * Build the Twitter sharing url with provided data.
+ *
+ * @param {string} title - The content title.
+ * @param {string} url - The content url.
+ * @returns {string} The Twitter url.
+ */
+ const buildTwitterUrl = (title: string, url: string): string => {
+ const titleParam = `text=${encodeURI(title)}`;
+ const urlParam = `url=${encodeURI(url)}`;
+ return `https://twitter.com/intent/tweet?${titleParam}&${urlParam}`;
+ };
+
+ /**
+ * Retrieve the sharing url by medium id.
+ *
+ * @param {SharingMedium} medium - A sharing medium id.
+ * @returns {string} The sharing url.
+ */
+ const getUrl = (medium: SharingMedium): string => {
+ const { excerpt, title, url } = data;
+
+ switch (medium) {
+ case 'diaspora':
+ return buildDiasporaUrl(title, url);
+ case 'email':
+ return buildEmailUrl(excerpt, title, url);
+ case 'facebook':
+ return buildFacebookUrl(url);
+ case 'journal-du-hacker':
+ return buildJdHUrl(title, url);
+ case 'linkedin':
+ return buildLinkedInUrl(url);
+ case 'twitter':
+ return buildTwitterUrl(title, url);
+ default:
+ return '#';
+ }
+ };
+
+ /**
+ * Get the sharing list items.
+ *
+ * @returns {JSX.Element[]} The sharing links wrapped with li element.
+ */
+ const getItems = (): JSX.Element[] => {
+ return media.map((medium) => (
+ <li key={medium}>
+ <SharingLink medium={medium} url={getUrl(medium)} />
+ </li>
+ ));
+ };
+
+ return (
+ <Widget expanded={expanded} level={level} title={widgetTitle} {...props}>
+ <ul className={`${styles.list} ${className}`}>{getItems()}</ul>
+ </Widget>
+ );
+};
+
+export default Sharing;
diff --git a/src/components/organisms/widgets/social-media.module.scss b/src/components/organisms/widgets/social-media.module.scss
new file mode 100644
index 0000000..01b6c0e
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/placeholders";
+
+.list {
+ @extend %reset-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-xs);
+ padding: 0 var(--spacing-2xs);
+}
diff --git a/src/components/organisms/widgets/social-media.stories.tsx b/src/components/organisms/widgets/social-media.stories.tsx
new file mode 100644
index 0000000..6c9de4d
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.stories.tsx
@@ -0,0 +1,61 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import SocialMediaWidget, { Media } from './social-media';
+
+/**
+ * SocialMedia - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Widgets',
+ component: SocialMediaWidget,
+ argTypes: {
+ level: {
+ control: {
+ type: 'number',
+ min: 1,
+ max: 6,
+ },
+ description: 'The heading level.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ media: {
+ description: 'The links data.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ title: {
+ control: {
+ type: 'text',
+ },
+ description: 'The widget title.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof SocialMediaWidget>;
+
+const Template: ComponentStory<typeof SocialMediaWidget> = (args) => (
+ <SocialMediaWidget {...args} />
+);
+
+const media: Media[] = [
+ { name: 'Github', url: '#' },
+ { name: 'LinkedIn', url: '#' },
+];
+
+/**
+ * Widgets Stories - Social media
+ */
+export const SocialMedia = Template.bind({});
+SocialMedia.args = {
+ media,
+ title: 'Follow me',
+ level: 2,
+};
diff --git a/src/components/organisms/widgets/social-media.test.tsx b/src/components/organisms/widgets/social-media.test.tsx
new file mode 100644
index 0000000..e40db30
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@test-utils';
+import SocialMedia, { Media } from './social-media';
+
+const media: Media[] = [
+ { name: 'Github', url: '#' },
+ { name: 'LinkedIn', url: '#' },
+];
+const title = 'Dolores ut ut';
+const titleLevel = 2;
+
+/**
+ * Next.js mock images with next/image component. So for now, I need to mock
+ * the svg files manually.
+ */
+jest.mock('@assets/images/social-media/github.svg', () => 'svg-file');
+jest.mock('@assets/images/social-media/linkedin.svg', () => 'svg-file');
+
+describe('SocialMedia', () => {
+ it('renders the widget title', () => {
+ render(<SocialMedia media={media} title={title} level={titleLevel} />);
+ expect(
+ screen.getByRole('heading', {
+ level: titleLevel,
+ name: new RegExp(title, 'i'),
+ })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the correct number of items', () => {
+ render(<SocialMedia media={media} title={title} level={titleLevel} />);
+ expect(screen.getAllByRole('listitem')).toHaveLength(media.length);
+ });
+});
diff --git a/src/components/organisms/widgets/social-media.tsx b/src/components/organisms/widgets/social-media.tsx
new file mode 100644
index 0000000..58b2f73
--- /dev/null
+++ b/src/components/organisms/widgets/social-media.tsx
@@ -0,0 +1,41 @@
+import SocialLink, {
+ type SocialLinkProps,
+} from '@components/atoms/links/social-link';
+import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
+import { FC } from 'react';
+import styles from './social-media.module.scss';
+
+export type Media = SocialLinkProps;
+
+export type SocialMediaProps = Pick<WidgetProps, 'level' | 'title'> & {
+ media: Media[];
+};
+
+/**
+ * Social Media widget component
+ *
+ * Render a social media list with links.
+ */
+const SocialMedia: FC<SocialMediaProps> = ({ media, ...props }) => {
+ /**
+ * Retrieve the social media items.
+ *
+ * @param {SocialMedia[]} links - An array of social media name and url.
+ * @returns {JSX.Element[]} The social links.
+ */
+ const getItems = (links: Media[]): JSX.Element[] => {
+ return links.map((link, index) => (
+ <li key={`media-${index}`}>
+ <SocialLink name={link.name} url={link.url} />
+ </li>
+ ));
+ };
+
+ return (
+ <Widget expanded={true} {...props}>
+ <ul className={styles.list}>{getItems(media)}</ul>
+ </Widget>
+ );
+};
+
+export default SocialMedia;
diff --git a/src/components/organisms/widgets/table-of-contents.module.scss b/src/components/organisms/widgets/table-of-contents.module.scss
new file mode 100644
index 0000000..36217ed
--- /dev/null
+++ b/src/components/organisms/widgets/table-of-contents.module.scss
@@ -0,0 +1,4 @@
+.list {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+}
diff --git a/src/components/organisms/widgets/table-of-contents.stories.tsx b/src/components/organisms/widgets/table-of-contents.stories.tsx
new file mode 100644
index 0000000..9490ee3
--- /dev/null
+++ b/src/components/organisms/widgets/table-of-contents.stories.tsx
@@ -0,0 +1,54 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import ToCWidget from './table-of-contents';
+
+/**
+ * TableOfContents - Storybook Meta
+ */
+export default {
+ title: 'Organisms/Widgets',
+ component: ToCWidget,
+ argTypes: {
+ wrapper: {
+ control: {
+ type: null,
+ },
+ description:
+ 'A reference to the HTML element that contains the headings.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ToCWidget>;
+
+const Template: ComponentStory<typeof ToCWidget> = (args) => (
+ <ToCWidget {...args} />
+);
+
+export const GetWrapper = () => {
+ const wrapper = document.createElement('div');
+ const firstTitle = document.createElement('h2');
+ const firstParagraph = document.createElement('p');
+ const secondTitle = document.createElement('h2');
+ const secondParagraph = document.createElement('p');
+
+ firstTitle.textContent = 'dignissimos odit odit';
+ firstParagraph.textContent =
+ 'Sint error saepe in. Vel doloribus facere deleniti minima magni. Consequatur veniam quia rerum praesentium eaque culpa culpa quas optio.';
+ secondTitle.textContent = 'aliquam exercitationem ut';
+ secondParagraph.textContent =
+ 'Doloribus sunt ut pariatur et praesentium rerum quam deserunt. Quod omnis quia qui quis debitis recusandae. Voluptate et impedit quam quidem quis id explicabo similique enim. Velit illum amet quos veniam consequatur amet nam sunt et. Et odit atque totam culpa officia saepe sed eaque consequatur.';
+
+ wrapper.append(...[firstTitle, firstParagraph, secondTitle, secondParagraph]);
+
+ return wrapper;
+};
+
+/**
+ * Widgets Stories - Table of Contents
+ */
+export const TableOfContents = Template.bind({});
+TableOfContents.args = {
+ wrapper: GetWrapper(),
+};
diff --git a/src/components/organisms/widgets/table-of-contents.test.tsx b/src/components/organisms/widgets/table-of-contents.test.tsx
new file mode 100644
index 0000000..2064f39
--- /dev/null
+++ b/src/components/organisms/widgets/table-of-contents.test.tsx
@@ -0,0 +1,12 @@
+import { render, screen } from '@test-utils';
+import TableOfContents from './table-of-contents';
+
+describe('TableOfContents', () => {
+ it('renders the ToC title', () => {
+ const divEl = document.createElement('div');
+ render(<TableOfContents wrapper={divEl} />);
+ expect(
+ screen.getByRole('heading', { level: 2, name: /Table of Contents/i })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/widgets/table-of-contents.tsx b/src/components/organisms/widgets/table-of-contents.tsx
new file mode 100644
index 0000000..800ff58
--- /dev/null
+++ b/src/components/organisms/widgets/table-of-contents.tsx
@@ -0,0 +1,55 @@
+import useHeadingsTree, { type Heading } from '@utils/hooks/use-headings-tree';
+import { FC } from 'react';
+import { useIntl } from 'react-intl';
+import LinksListWidget, { type LinksListItems } from './links-list-widget';
+import styles from './table-of-contents.module.scss';
+
+type TableOfContentsProps = {
+ /**
+ * A reference to the HTML element that contains the headings.
+ */
+ wrapper: HTMLElement;
+};
+
+/**
+ * Table of Contents widget component
+ *
+ * Render a table of contents.
+ */
+const TableOfContents: FC<TableOfContentsProps> = ({ wrapper }) => {
+ const intl = useIntl();
+ const headingsTree = useHeadingsTree(wrapper);
+ const title = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'TableOfContents: the widget title',
+ id: 'WKG9wj',
+ });
+
+ /**
+ * Convert an headings tree to list items.
+ *
+ * @param {Heading[]} tree - The headings tree.
+ * @returns {LinksListItems[]} The list items.
+ */
+ const getItems = (tree: Heading[]): LinksListItems[] => {
+ return tree.map((heading) => {
+ return {
+ name: heading.title,
+ url: `#${heading.id}`,
+ child: getItems(heading.children),
+ };
+ });
+ };
+
+ return (
+ <LinksListWidget
+ kind="ordered"
+ title={title}
+ level={2}
+ items={getItems(headingsTree)}
+ className={styles.list}
+ />
+ );
+};
+
+export default TableOfContents;