From 5a6584777e42e6e3e55294d357cb0adafe2853e7 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 22 Apr 2022 18:46:48 +0200
Subject: chore: add a Layout component

It defines the different components used by all other layouts.
---
 src/components/templates/layout/layout.tsx | 125 +++++++++++++++++++++++++++++
 1 file changed, 125 insertions(+)
 create mode 100644 src/components/templates/layout/layout.tsx

(limited to 'src/components/templates/layout/layout.tsx')

diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
new file mode 100644
index 0000000..601ced4
--- /dev/null
+++ b/src/components/templates/layout/layout.tsx
@@ -0,0 +1,125 @@
+import photo from '@assets/images/armand-philippot.jpg';
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Career from '@components/atoms/icons/career';
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import ComputerScreen from '@components/atoms/icons/computer-screen';
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import PostsStack from '@components/atoms/icons/posts-stack';
+import Main from '@components/atoms/layout/main';
+import NoScript from '@components/atoms/layout/no-script';
+import Footer from '@components/organisms/layout/footer';
+import Header, { HeaderProps } from '@components/organisms/layout/header';
+import { settings } from '@utils/config';
+import { FC, ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './layout.module.scss';
+
+export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
+  /**
+   * The layout main content.
+   */
+  children: ReactNode;
+  /**
+   * Set additional classnames to the article element.
+   */
+  className?: string;
+};
+
+/**
+ * Layout component
+ *
+ * Render the base layout used by all pages.
+ */
+const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
+  const intl = useIntl();
+  const skipToContent = intl.formatMessage({
+    defaultMessage: 'Skip to content',
+    description: 'Layout: Skip to content link',
+    id: 'K4rYdT',
+  });
+  const noScript = intl.formatMessage({
+    defaultMessage:
+      'Warning: If you want to benefit from all features (search for example), please activate Javascript.',
+    description: 'Layout: noscript message',
+    id: '7jVUT6',
+  });
+
+  const copyright = {
+    dates: {
+      start: settings.copyright.startYear,
+      end: settings.copyright.endYear,
+    },
+    owner: settings.name,
+    icon: <CCBySA />,
+  };
+
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Layout: main nav - home link',
+    id: 'bojYF5',
+  });
+  const blogLabel = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'Layout: main nav - blog link',
+    id: 'D8vB38',
+  });
+  const projectsLabel = intl.formatMessage({
+    defaultMessage: 'Projects',
+    description: 'Layout: main nav - projects link',
+    id: 'qnwsWV',
+  });
+  const cvLabel = intl.formatMessage({
+    defaultMessage: 'CV',
+    description: 'Layout: main nav - cv link',
+    id: 'R895yC',
+  });
+  const contactLabel = intl.formatMessage({
+    defaultMessage: 'Contact',
+    description: 'Layout: main nav - contact link',
+    id: 'AE4kCD',
+  });
+
+  const nav: HeaderProps['nav'] = [
+    { id: 'home', label: homeLabel, href: '#', logo: <Home /> },
+    { id: 'blog', label: blogLabel, href: '#', logo: <PostsStack /> },
+    {
+      id: 'projects',
+      label: projectsLabel,
+      href: '#',
+      logo: <ComputerScreen />,
+    },
+    { id: 'cv', label: cvLabel, href: '#', logo: <Career /> },
+    { id: 'contact', label: contactLabel, href: '#', logo: <Envelop /> },
+  ];
+
+  return (
+    <>
+      <noscript>
+        <div className={styles['noscript-spacing']}></div>
+      </noscript>
+      <span tabIndex={-1}></span>
+      <ButtonLink target="#main" className="screen-reader-text">
+        {skipToContent}
+      </ButtonLink>
+      <Header
+        title={settings.name}
+        baseline={settings.baseline.fr}
+        photo={photo.src}
+        nav={nav}
+        isHome={isHome}
+        className={styles.header}
+        withLink={true}
+      />
+      <Main id="main" className={styles.main}>
+        <article {...props}>{children}</article>
+      </Main>
+      <Footer copyright={copyright} topId="top" className={styles.footer} />
+      <noscript>
+        <NoScript message={noScript} position="top" />
+      </noscript>
+    </>
+  );
+};
+
+export default Layout;
-- 
cgit v1.2.3


From 732d0943f8041d76262222a092b014f2557085ef Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 2 May 2022 18:57:29 +0200
Subject: chore: add homepage

---
 src/components/atoms/lists/list.module.scss        |  16 +
 src/components/atoms/lists/list.stories.tsx        |  13 +-
 src/components/atoms/lists/list.tsx                |   4 +-
 src/components/molecules/layout/card.module.scss   |   3 +-
 src/components/molecules/layout/card.tsx           |   2 +-
 src/components/molecules/layout/page-header.tsx    |   2 +-
 src/components/molecules/nav/nav.stories.tsx       |  26 ++
 src/components/molecules/nav/nav.tsx               |   7 +-
 .../organisms/layout/cards-list.module.scss        |   4 +-
 .../organisms/layout/cards-list.stories.tsx        |  13 +
 src/components/organisms/layout/cards-list.tsx     |   8 +-
 src/components/templates/layout/layout.module.scss |   9 -
 src/components/templates/layout/layout.stories.tsx |  10 +-
 src/components/templates/layout/layout.tsx         | 107 ++++--
 src/pages/index.tsx                                | 365 +++++++++++++++++++++
 src/services/graphql/articles.ts                   |  52 ++-
 src/styles/pages/Home.module.scss                  |  49 ---
 src/styles/pages/home.module.scss                  |  36 ++
 src/utils/hooks/use-settings.tsx                   | 112 +++++++
 19 files changed, 745 insertions(+), 93 deletions(-)
 create mode 100644 src/pages/index.tsx
 delete mode 100644 src/styles/pages/Home.module.scss
 create mode 100644 src/styles/pages/home.module.scss
 create mode 100644 src/utils/hooks/use-settings.tsx

(limited to 'src/components/templates/layout/layout.tsx')

diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss
index df3b49c..f647072 100644
--- a/src/components/atoms/lists/list.module.scss
+++ b/src/components/atoms/lists/list.module.scss
@@ -1,3 +1,5 @@
+@use "@styles/abstracts/placeholders";
+
 .list {
   margin: 0;
 
@@ -36,4 +38,18 @@
       margin-bottom: var(--spacing-2xs);
     }
   }
+
+  &--flex {
+    @extend %reset-list;
+
+    display: flex;
+    flex-flow: row wrap;
+    gap: var(--spacing-sm);
+  }
+
+  &--flex &--flex {
+    display: initial;
+    position: relative;
+    top: var(--spacing-2xs);
+  }
 }
diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx
index 3a80962..54fdd3a 100644
--- a/src/components/atoms/lists/list.stories.tsx
+++ b/src/components/atoms/lists/list.stories.tsx
@@ -39,8 +39,8 @@ export default {
       control: {
         type: 'select',
       },
-      description: 'The list kind: ordered or unordered.',
-      options: ['ordered', 'unordered'],
+      description: 'The list kind: flex, ordered or unordered.',
+      options: ['flex', 'ordered', 'unordered'],
       table: {
         category: 'Options',
         defaultValue: { summary: 'unordered' },
@@ -71,6 +71,15 @@ const items: ListItem[] = [
   { id: 'item-4', value: 'Item 4' },
 ];
 
+/**
+ * List Stories - Flex list
+ */
+export const Flex = Template.bind({});
+Flex.args = {
+  items,
+  kind: 'flex',
+};
+
 /**
  * List Stories - Ordered list
  */
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
index 6726802..711ade1 100644
--- a/src/components/atoms/lists/list.tsx
+++ b/src/components/atoms/lists/list.tsx
@@ -30,9 +30,9 @@ export type ListProps = {
    */
   itemsClassName?: string;
   /**
-   * The list kind (ordered or unordered).
+   * The list kind.
    */
-  kind?: 'ordered' | 'unordered';
+  kind?: 'ordered' | 'unordered' | 'flex';
   /**
    * Set margin between list items. Default: true.
    */
diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss
index 85c319a..d5b9836 100644
--- a/src/components/molecules/layout/card.module.scss
+++ b/src/components/molecules/layout/card.module.scss
@@ -19,7 +19,8 @@
 
   .cover {
     align-self: flex-start;
-    max-height: fun.convert-px(150);
+    place-content: center;
+    height: fun.convert-px(150);
     margin: auto;
     border-bottom: fun.convert-px(1) solid var(--color-border);
   }
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index 15927e9..89f100e 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -93,7 +93,7 @@ const Card: FC<CardProps> = ({
             {title}
           </Heading>
         </header>
-        {tagline && <div className={styles.tagline}>{tagline}</div>}
+        <div className={styles.tagline}>{tagline}</div>
         {meta && (
           <footer className={styles.footer}>
             <DescriptionList
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
index 174e246..1663085 100644
--- a/src/components/molecules/layout/page-header.tsx
+++ b/src/components/molecules/layout/page-header.tsx
@@ -11,7 +11,7 @@ export type PageHeaderProps = {
   /**
    * The page introduction.
    */
-  intro?: string;
+  intro?: string | JSX.Element;
   /**
    * The page metadata.
    */
diff --git a/src/components/molecules/nav/nav.stories.tsx b/src/components/molecules/nav/nav.stories.tsx
index 25455fd..5cef5f0 100644
--- a/src/components/molecules/nav/nav.stories.tsx
+++ b/src/components/molecules/nav/nav.stories.tsx
@@ -11,6 +11,19 @@ export default {
   title: 'Molecules/Navigation/Nav',
   component: NavComponent,
   argTypes: {
+    'aria-label': {
+      control: {
+        type: 'text',
+      },
+      description: 'An accessible name for the navigation.',
+      table: {
+        category: 'Accessibility',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -46,6 +59,19 @@ export default {
         required: true,
       },
     },
+    listClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the navigation list.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
   },
   decorators: [
     (Story) => (
diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx
index 2666ea2..581f813 100644
--- a/src/components/molecules/nav/nav.tsx
+++ b/src/components/molecules/nav/nav.tsx
@@ -23,6 +23,10 @@ export type NavItem = {
 };
 
 export type NavProps = {
+  /**
+   * An accessible name.
+   */
+  'aria-label'?: string;
   /**
    * Set additional classnames to the navigation wrapper.
    */
@@ -51,6 +55,7 @@ const Nav: FC<NavProps> = ({
   items,
   kind,
   listClassName = '',
+  ...props
 }) => {
   const kindClass = `nav--${kind}`;
 
@@ -71,7 +76,7 @@ const Nav: FC<NavProps> = ({
   };
 
   return (
-    <nav className={`${styles[kindClass]} ${className}`}>
+    <nav className={`${styles[kindClass]} ${className}`} {...props}>
       <ul className={`${styles.nav__list} ${listClassName}`}>{getItems()}</ul>
     </nav>
   );
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
index 9fe428c..2763585 100644
--- a/src/components/organisms/layout/cards-list.module.scss
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -1,12 +1,10 @@
 @use "@styles/abstracts/placeholders";
 
 .wrapper {
-  --card-width: 30ch;
-
   display: grid;
   grid-template-columns: repeat(
     auto-fit,
-    min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width))
+    min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width, 30ch))
   );
   gap: var(--spacing-sm);
   place-content: center;
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
index 7ff4365..fe0ebfd 100644
--- a/src/components/organisms/layout/cards-list.stories.tsx
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -12,6 +12,19 @@ export default {
     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',
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 33ffe23..1558d7c 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -14,6 +14,10 @@ export type CardsListItem = Omit<
 };
 
 export type CardsListProps = {
+  /**
+   * Set additional classnames to the list wrapper.
+   */
+  className?: string;
   /**
    * The cover fit.
    */
@@ -38,6 +42,7 @@ export type CardsListProps = {
  * Return a list of Card components.
  */
 const CardsList: FC<CardsListProps> = ({
+  className = '',
   coverFit,
   items,
   kind = 'unordered',
@@ -70,9 +75,10 @@ const CardsList: FC<CardsListProps> = ({
 
   return (
     <List
+      kind="flex"
       items={getCards(items)}
       withMargin={false}
-      className={`${styles.wrapper} ${styles[kindModifier]}`}
+      className={`${styles.wrapper} ${styles[kindModifier]} ${className}`}
     />
   );
 };
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 3533257..806d2d7 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -1,15 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 @use "@styles/abstracts/mixins" as mix;
 
-:global {
-  #__next {
-    flex: 1;
-    display: flex;
-    flex-flow: column nowrap;
-    min-height: 100vh;
-  }
-}
-
 .header {
   border-bottom: fun.convert-px(3) solid var(--color-border-light);
 }
diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
index f3579e3..2415412 100644
--- a/src/components/templates/layout/layout.stories.tsx
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -36,7 +36,15 @@ export default {
   decorators: [
     (Story) => (
       <IntlProvider locale="en">
-        <div id="__next">
+        <div
+          id="__next"
+          style={{
+            flex: 1,
+            display: 'flex',
+            flexFlow: 'column nowrap',
+            minHeight: '100vh',
+          }}
+        >
           <Story />
         </div>
       </IntlProvider>
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 601ced4..e1be1af 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -1,4 +1,3 @@
-import photo from '@assets/images/armand-philippot.jpg';
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Career from '@components/atoms/icons/career';
 import CCBySA from '@components/atoms/icons/cc-by-sa';
@@ -8,13 +7,19 @@ import Home from '@components/atoms/icons/home';
 import PostsStack from '@components/atoms/icons/posts-stack';
 import Main from '@components/atoms/layout/main';
 import NoScript from '@components/atoms/layout/no-script';
-import Footer from '@components/organisms/layout/footer';
-import Header, { HeaderProps } from '@components/organisms/layout/header';
-import { settings } from '@utils/config';
+import Footer, { FooterProps } from '@components/organisms/layout/footer';
+import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import useSettings from '@utils/hooks/use-settings';
+import Script from 'next/script';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
+import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
 import styles from './layout.module.scss';
 
+export type QueryAction = SearchAction & {
+  'query-input': string;
+};
+
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
   /**
    * The layout main content.
@@ -33,6 +38,9 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  */
 const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
   const intl = useIntl();
+  const { website } = useSettings();
+  const { baseline, copyright, locales, name, picture, url } = website;
+
   const skipToContent = intl.formatMessage({
     defaultMessage: 'Skip to content',
     description: 'Layout: Skip to content link',
@@ -45,12 +53,12 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
     id: '7jVUT6',
   });
 
-  const copyright = {
+  const copyrightData = {
     dates: {
-      start: settings.copyright.startYear,
-      end: settings.copyright.endYear,
+      start: copyright.start,
+      end: copyright.end,
     },
-    owner: settings.name,
+    owner: name,
     icon: <CCBySA />,
   };
 
@@ -80,21 +88,77 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
     id: 'AE4kCD',
   });
 
-  const nav: HeaderProps['nav'] = [
-    { id: 'home', label: homeLabel, href: '#', logo: <Home /> },
-    { id: 'blog', label: blogLabel, href: '#', logo: <PostsStack /> },
+  const mainNav: HeaderProps['nav'] = [
+    { id: 'home', label: homeLabel, href: '/', logo: <Home /> },
+    { id: 'blog', label: blogLabel, href: '/blog', logo: <PostsStack /> },
     {
       id: 'projects',
       label: projectsLabel,
-      href: '#',
+      href: '/projets',
       logo: <ComputerScreen />,
     },
-    { id: 'cv', label: cvLabel, href: '#', logo: <Career /> },
-    { id: 'contact', label: contactLabel, href: '#', logo: <Envelop /> },
+    { id: 'cv', label: cvLabel, href: '/cv', logo: <Career /> },
+    { id: 'contact', label: contactLabel, href: '/contact', logo: <Envelop /> },
+  ];
+
+  const legalNoticeLabel = intl.formatMessage({
+    defaultMessage: 'Legal notice',
+    description: 'Layout: Legal notice label',
+    id: 'nwbzKm',
+  });
+
+  const footerNav: FooterProps['navItems'] = [
+    { id: 'legal-notice', label: legalNoticeLabel, href: '/mentions-legales' },
   ];
 
+  const searchActionSchema: QueryAction = {
+    '@type': 'SearchAction',
+    target: {
+      '@type': 'EntryPoint',
+      urlTemplate: `${url}/recherche?s={search_term_string}`,
+    },
+    query: 'required',
+    'query-input': 'required name=search_term_string',
+  };
+
+  const schemaJsonLd: WithContext<WebSite> = {
+    '@context': 'https://schema.org',
+    '@id': `${url}`,
+    '@type': 'WebSite',
+    name: name,
+    description: baseline,
+    url: url,
+    author: { '@id': `${url}/#branding` },
+    copyrightYear: Number(copyright.start),
+    creator: { '@id': `${url}/#branding` },
+    editor: { '@id': `${url}/#branding` },
+    inLanguage: locales.default,
+    potentialAction: searchActionSchema,
+  };
+
+  const brandingSchema: WithContext<Person> = {
+    '@context': 'https://schema.org',
+    '@type': 'Person',
+    '@id': `${url}/#branding`,
+    name: name,
+    url: url,
+    jobTitle: baseline,
+    image: picture.src,
+    subjectOf: { '@id': `${url}` },
+  };
+
   return (
     <>
+      <Script
+        id="schema-layout"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      ></Script>
+      <Script
+        id="schema-branding"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
+      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
@@ -103,10 +167,10 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
         {skipToContent}
       </ButtonLink>
       <Header
-        title={settings.name}
-        baseline={settings.baseline.fr}
-        photo={photo.src}
-        nav={nav}
+        title={name}
+        baseline={baseline}
+        photo={picture}
+        nav={mainNav}
         isHome={isHome}
         className={styles.header}
         withLink={true}
@@ -114,7 +178,12 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
       <Main id="main" className={styles.main}>
         <article {...props}>{children}</article>
       </Main>
-      <Footer copyright={copyright} topId="top" className={styles.footer} />
+      <Footer
+        copyright={copyrightData}
+        navItems={footerNav}
+        topId="top"
+        className={styles.footer}
+      />
       <noscript>
         <NoScript message={noScript} position="top" />
       </noscript>
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..c965320
--- /dev/null
+++ b/src/pages/index.tsx
@@ -0,0 +1,365 @@
+import FeedIcon from '@assets/images/icon-feed.svg';
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Envelop from '@components/atoms/icons/envelop';
+import Column, { type ColumnProps } from '@components/atoms/layout/column';
+import Section, { type SectionProps } from '@components/atoms/layout/section';
+import List, { type ListItem } from '@components/atoms/lists/list';
+import ResponsiveImage, {
+  type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Columns, {
+  type ColumnsProps,
+} from '@components/molecules/layout/columns';
+import CardsList, {
+  type CardsListItem,
+} from '@components/organisms/layout/cards-list';
+import Layout from '@components/templates/layout/layout';
+import HomePageContent from '@content/pages/homepage.mdx';
+import { getArticlesCard } from '@services/graphql/articles';
+import styles from '@styles/pages/home.module.scss';
+import { ArticleCard } from '@ts/types/app';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+import { NestedMDXComponents } from 'mdx/types';
+import { GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import Script from 'next/script';
+import { ReactElement } from 'react';
+import { useIntl } from 'react-intl';
+import { Graph, WebPage } from 'schema-dts';
+
+type HomeProps = {
+  recentPosts: ArticleCard[];
+  translation?: Messages;
+};
+
+/**
+ * Home page.
+ */
+const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
+  const intl = useIntl();
+
+  /**
+   * Retrieve a list of coding links.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const CodingLinks = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'web-development',
+        value: (
+          <ButtonLink target="/thematique/developpement-web">
+            {intl.formatMessage({
+              defaultMessage: 'Web development',
+              description: 'HomePage: link to web development thematic',
+              id: 'vkF/RP',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'projects',
+        value: (
+          <ButtonLink target="/projets">
+            {intl.formatMessage({
+              defaultMessage: 'Projects',
+              description: 'HomePage: link to projects',
+              id: 'N44SOc',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve a list of Coldark repositories.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const ColdarkRepos = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'coldark-github',
+        value: (
+          <ButtonLink
+            target="https://github.com/ArmandPhilippot/coldark"
+            external={true}
+          >
+            {intl.formatMessage({
+              defaultMessage: 'Github',
+              description: 'HomePage: Github link',
+              id: '3f3PzH',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'coldark-gitlab',
+        value: (
+          <ButtonLink
+            target="https://gitlab.com/ArmandPhilippot/coldark"
+            external={true}
+          >
+            {intl.formatMessage({
+              defaultMessage: 'Gitlab',
+              description: 'HomePage: Gitlab link',
+              id: '7AnwZ7',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve a list of links related to Free thematic.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const LibreLinks = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'free',
+        value: (
+          <ButtonLink target="/thematique/libre">
+            {intl.formatMessage({
+              defaultMessage: 'Free',
+              description: 'HomePage: link to free thematic',
+              id: 'w8GrOf',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'linux',
+        value: (
+          <ButtonLink target="/thematique/linux">
+            {intl.formatMessage({
+              defaultMessage: 'Linux',
+              description: 'HomePage: link to Linux thematic',
+              id: 'jASD7k',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve the Shaarli link.
+   *
+   * @returns {JSX.Element} - A list of links
+   */
+  const ShaarliLink = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'shaarli',
+        value: (
+          <ButtonLink target="https://shaarli.armandphilippot.com/">
+            {intl.formatMessage({
+              defaultMessage: 'Shaarli',
+              description: 'HomePage: link to Shaarli',
+              id: 'i5L19t',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve the additional links.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const MoreLinks = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'contact-me',
+        value: (
+          <ButtonLink target="/contact">
+            <Envelop className={styles.icon} />
+            {intl.formatMessage({
+              defaultMessage: 'Contact me',
+              description: 'HomePage: contact button text',
+              id: 'sO/Iwj',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'rss-feed',
+        value: (
+          <ButtonLink target="/feed">
+            <FeedIcon className={`${styles.icon} ${styles['icon--feed']}`} />
+            {intl.formatMessage({
+              defaultMessage: 'Subscribe',
+              description: 'HomePage: RSS feed subscription text',
+              id: 'T4YA64',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Get a cards list of recent posts.
+   *
+   * @returns {JSX.Element} - The cards list.
+   */
+  const getRecentPosts = (): JSX.Element => {
+    const posts: CardsListItem[] = recentPosts.map((post) => {
+      return {
+        cover: post.cover,
+        id: post.slug,
+        meta: [
+          {
+            id: 'publication',
+            term: intl.formatMessage({
+              defaultMessage: 'Published on:',
+              description: 'HomePage: publication date label',
+              id: 'pT5nHk',
+            }),
+            value: [post.dates.publication],
+          },
+        ],
+        title: post.title,
+        url: `/article/${post.slug}`,
+      };
+    });
+
+    return (
+      <CardsList
+        items={posts}
+        titleLevel={3}
+        className={`${styles.list} ${styles['list--cards']}`}
+      />
+    );
+  };
+
+  /**
+   * Create the page sections.
+   *
+   * @param {object} obj - An object containing the section body.
+   * @param {ReactElement[]} obj.children - The section body.
+   * @returns {JSX.Element} A section element.
+   */
+  const getSection = ({
+    children,
+    variant,
+  }: {
+    children: ReactElement[];
+    variant: SectionProps['variant'];
+  }): JSX.Element => {
+    const [headingEl, ...content] = children;
+    const title = headingEl.props.children;
+
+    return (
+      <Section
+        title={title}
+        content={content}
+        variant={variant}
+        className={styles.section}
+      />
+    );
+  };
+
+  const components: NestedMDXComponents = {
+    CodingLinks: CodingLinks,
+    ColdarkRepos: ColdarkRepos,
+    Column: (props: ColumnProps) => <Column {...props} />,
+    Columns: (props: ColumnsProps) => (
+      <Columns className={styles.columns} {...props} />
+    ),
+    Image: (props: ResponsiveImageProps) => <ResponsiveImage {...props} />,
+    LibreLinks: LibreLinks,
+    MoreLinks: MoreLinks,
+    RecentPosts: getRecentPosts,
+    Section: getSection,
+    ShaarliLink: ShaarliLink,
+  };
+
+  const { website } = useSettings();
+
+  const pageTitle = intl.formatMessage(
+    {
+      defaultMessage: '{websiteName} | Front-end developer: WordPress/React',
+      description: 'HomePage: SEO - Page title',
+      id: 'PXp2hv',
+    },
+    { websiteName: website.name }
+  );
+  const pageDescription = intl.formatMessage(
+    {
+      defaultMessage:
+        '{websiteName} is a front-end developer located in France. He codes and he writes mostly about web development and open-source.',
+      description: 'HomePage: SEO - Meta description',
+      id: 'tMuNTy',
+    },
+    { websiteName: website.name }
+  );
+
+  const webpageSchema: WebPage = {
+    '@id': `${website.url}/#home`,
+    '@type': 'WebPage',
+    name: pageTitle,
+    description: pageDescription,
+    author: { '@id': `${website.url}/#branding` },
+    creator: { '@id': `${website.url}/#branding` },
+    editor: { '@id': `${website.url}/#branding` },
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${website.url}`,
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema],
+  };
+
+  return (
+    <Layout>
+      <Head>
+        <title>{pageTitle}</title>
+        <meta name="description" content={pageDescription} />
+        <meta property="og:url" content={website.url} />
+        <meta property="og:title" content={pageTitle} />
+        <meta property="og:description" content={pageDescription} />
+      </Head>
+      <Script
+        id="schema-homepage"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <HomePageContent components={components} />
+    </Layout>
+  );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+  const translation = await loadTranslation(locale);
+  const recentPosts = await getArticlesCard({ first: 3 });
+
+  return {
+    props: {
+      recentPosts,
+      translation,
+    },
+  };
+};
+
+export default HomePage;
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index e5ce7a5..7aff3e0 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -1,10 +1,19 @@
-import { Article } from '@ts/types/app';
-import { RawArticle, TotalItems } from '@ts/types/raw-data';
+import { type Article, type ArticleCard } from '@ts/types/app';
+import {
+  type RawArticle,
+  type RawArticlePreview,
+  type TotalItems,
+} from '@ts/types/raw-data';
 import { getAuthorFromRawData } from '@utils/helpers/author';
+import { getDates } from '@utils/helpers/dates';
 import { getImageFromRawData } from '@utils/helpers/images';
 import { getPageLinkFromRawData } from '@utils/helpers/pages';
 import { EdgesVars, fetchAPI, getAPIUrl, PageInfo } from './api';
-import { articlesQuery, totalArticlesQuery } from './articles.query';
+import {
+  articlesCardQuery,
+  articlesQuery,
+  totalArticlesQuery,
+} from './articles.query';
 
 /**
  * Retrieve the total number of articles.
@@ -102,3 +111,40 @@ export const getArticles = async ({
     pageInfo: response.posts.pageInfo,
   };
 };
+
+/**
+ * Convert a raw article preview to an article card.
+ *
+ * @param {RawArticlePreview} data - A raw article preview.
+ * @returns {ArticleCard} An article card.
+ */
+const getArticleCardFromRawData = (data: RawArticlePreview): ArticleCard => {
+  const { databaseId, date, featuredImage, slug, title } = data;
+
+  return {
+    cover: featuredImage ? getImageFromRawData(featuredImage.node) : undefined,
+    dates: getDates(date, ''),
+    id: databaseId,
+    slug,
+    title,
+  };
+};
+
+/**
+ * Retrieve the given number of article cards from API.
+ *
+ * @param {EdgesVars} obj - An object.
+ * @param {number} obj.first - The number of articles.
+ * @returns {Promise<ArticleCard[]>} - The article cards data.
+ */
+export const getArticlesCard = async ({
+  first,
+}: EdgesVars): Promise<ArticleCard[]> => {
+  const response = await fetchAPI<RawArticlePreview, typeof articlesCardQuery>({
+    api: getAPIUrl(),
+    query: articlesCardQuery,
+    variables: { first },
+  });
+
+  return response.posts.nodes.map((node) => getArticleCardFromRawData(node));
+};
diff --git a/src/styles/pages/Home.module.scss b/src/styles/pages/Home.module.scss
deleted file mode 100644
index 8225a57..0000000
--- a/src/styles/pages/Home.module.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-@use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/placeholders";
-
-.links-list {
-  @extend %flex-list;
-
-  gap: var(--spacing-md);
-  margin: 0 0 var(--spacing-md);
-}
-
-.icon--feed {
-  width: fun.convert-px(20);
-}
-
-:global {
-  [data-theme="dark"] {
-    :local {
-      .icon--feed {
-        filter: brightness(0.8) contrast(1.1);
-      }
-    }
-  }
-}
-
-.section {
-  --icon-size: #{fun.convert-px(20)};
-
-  composes: grid from "@styles/layout/_grid.scss";
-  padding: var(--spacing-md) 0;
-  background: var(--color-bg-secondary);
-
-  &:not(:last-child) {
-    border-bottom: fun.convert-px(1) solid var(--color-border);
-  }
-
-  &:nth-child(2n) {
-    background: var(--color-bg);
-  }
-
-  > * {
-    grid-column: 2;
-  }
-
-  :global {
-    .wp-block-columns {
-      margin: 0 0 var(--spacing-md);
-    }
-  }
-}
diff --git a/src/styles/pages/home.module.scss b/src/styles/pages/home.module.scss
new file mode 100644
index 0000000..873a5a9
--- /dev/null
+++ b/src/styles/pages/home.module.scss
@@ -0,0 +1,36 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.section {
+  --card-width: 25ch;
+
+  &:last-of-type {
+    border-bottom: none;
+  }
+}
+
+.columns {
+  margin: 0 0 var(--spacing-sm);
+}
+
+.list {
+  margin: 0 0 var(--spacing-sm);
+
+  &--cards {
+    @include mix.media("screen") {
+      @include mix.dimensions("md") {
+        margin: 0 calc(var(--spacing-sm) * -1) var(--spacing-sm);
+      }
+    }
+  }
+}
+
+.icon {
+  --icon-size: #{fun.convert-px(20)};
+
+  margin-right: var(--spacing-2xs);
+
+  &--feed {
+    width: var(--icon-size);
+  }
+}
diff --git a/src/utils/hooks/use-settings.tsx b/src/utils/hooks/use-settings.tsx
new file mode 100644
index 0000000..a45e934
--- /dev/null
+++ b/src/utils/hooks/use-settings.tsx
@@ -0,0 +1,112 @@
+import photo from '@assets/images/armand-philippot.jpg';
+import { settings } from '@utils/config';
+import { useRouter } from 'next/router';
+
+export type BlogSettings = {
+  /**
+   * The number of posts per page.
+   */
+  postsPerPage: number;
+};
+
+export type CopyrightSettings = {
+  /**
+   * The copyright end year.
+   */
+  end: string;
+  /**
+   * The copyright start year.
+   */
+  start: string;
+};
+
+export type LocaleSettings = {
+  /**
+   * The default locale.
+   */
+  default: string;
+  /**
+   * The supported locales.
+   */
+  supported: string[];
+};
+
+export type PictureSettings = {
+  /**
+   * The picture height.
+   */
+  height: number;
+  /**
+   * The picture url.
+   */
+  src: string;
+  /**
+   * The picture width.
+   */
+  width: number;
+};
+
+export type WebsiteSettings = {
+  /**
+   * The website name.
+   */
+  name: string;
+  /**
+   * The website baseline.
+   */
+  baseline: string;
+  /**
+   * The website copyright dates.
+   */
+  copyright: CopyrightSettings;
+  /**
+   * The website locales.
+   */
+  locales: LocaleSettings;
+  /**
+   * A picture representing the website.
+   */
+  picture: PictureSettings;
+  /**
+   * The website url.
+   */
+  url: string;
+};
+
+export type UseSettingsReturn = {
+  blog: BlogSettings;
+  website: WebsiteSettings;
+};
+
+/**
+ * Retrieve the website and blog settings.
+ *
+ * @returns {UseSettingsReturn} - An object describing settings.
+ */
+const useSettings = (): UseSettingsReturn => {
+  const { baseline, copyright, locales, name, postsPerPage, url } = settings;
+  const router = useRouter();
+  const locale = router.locale || locales.defaultLocale;
+
+  return {
+    blog: {
+      postsPerPage,
+    },
+    website: {
+      baseline: locale.startsWith('en') ? baseline.en : baseline.fr,
+      copyright: {
+        end: copyright.endYear,
+        start: copyright.startYear,
+      },
+      locales: {
+        default: locales.defaultLocale,
+        supported: locales.supported,
+      },
+      name,
+      picture: photo,
+      url,
+    },
+  };
+};
+
+export default useSettings;
-- 
cgit v1.2.3


From 235fe67d770f83131c9ec10b99012319440db690 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sun, 15 May 2022 16:36:58 +0200
Subject: chore: add Search page

---
 .../organisms/forms/search-form.stories.tsx        |   9 +-
 .../organisms/forms/search-form.test.tsx           |   4 +-
 src/components/organisms/forms/search-form.tsx     |  14 +-
 src/components/organisms/layout/header.stories.tsx |   9 +-
 src/components/organisms/layout/header.test.tsx    |  12 +-
 src/components/organisms/layout/header.tsx         |  21 +-
 .../organisms/modals/search-modal.stories.tsx      |  11 +-
 .../organisms/modals/search-modal.test.tsx         |   2 +-
 src/components/organisms/modals/search-modal.tsx   |   8 +-
 .../organisms/toolbar/search.stories.tsx           |  10 +-
 src/components/organisms/toolbar/search.test.tsx   |   6 +-
 src/components/organisms/toolbar/search.tsx        |  12 +-
 .../organisms/toolbar/toolbar.stories.tsx          |  11 +-
 src/components/organisms/toolbar/toolbar.test.tsx  |   2 +-
 src/components/organisms/toolbar/toolbar.tsx       |   7 +-
 src/components/templates/layout/layout.tsx         |   3 +-
 src/pages/blog/index.tsx                           |   4 +-
 src/pages/recherche/index.tsx                      | 324 +++++++++++++++++++++
 src/services/graphql/api.ts                        |  15 +-
 src/services/graphql/articles.query.ts             |   4 +-
 src/services/graphql/articles.ts                   |   3 +-
 src/services/graphql/contact.ts                    |   4 +-
 src/utils/hooks/use-data-from-api.tsx              |  23 ++
 src/utils/hooks/use-pagination.tsx                 |   3 +-
 24 files changed, 436 insertions(+), 85 deletions(-)
 create mode 100644 src/pages/recherche/index.tsx
 create mode 100644 src/utils/hooks/use-data-from-api.tsx

(limited to 'src/components/templates/layout/layout.tsx')

diff --git a/src/components/organisms/forms/search-form.stories.tsx b/src/components/organisms/forms/search-form.stories.tsx
index 7f4c7c0..6ea6122 100644
--- a/src/components/organisms/forms/search-form.stories.tsx
+++ b/src/components/organisms/forms/search-form.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SearchForm from './search-form';
 
 /**
@@ -10,6 +9,7 @@ export default {
   component: SearchForm,
   args: {
     hideLabel: false,
+    searchPage: '#',
   },
   argTypes: {
     className: {
@@ -40,13 +40,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SearchForm>;
 
 const Template: ComponentStory<typeof SearchForm> = (args) => (
diff --git a/src/components/organisms/forms/search-form.test.tsx b/src/components/organisms/forms/search-form.test.tsx
index 4e3d285..59a2f68 100644
--- a/src/components/organisms/forms/search-form.test.tsx
+++ b/src/components/organisms/forms/search-form.test.tsx
@@ -3,14 +3,14 @@ import SearchForm from './search-form';
 
 describe('SearchForm', () => {
   it('renders a search input', () => {
-    render(<SearchForm />);
+    render(<SearchForm searchPage="#" />);
     expect(
       screen.getByRole('searchbox', { name: 'Search for:' })
     ).toBeInTheDocument();
   });
 
   it('renders a submit button', () => {
-    render(<SearchForm />);
+    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
index 18b7c08..56d3895 100644
--- a/src/components/organisms/forms/search-form.tsx
+++ b/src/components/organisms/forms/search-form.tsx
@@ -4,18 +4,24 @@ import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
 import LabelledField, {
   type LabelledFieldProps,
 } from '@components/molecules/forms/labelled-field';
+import { useRouter } from 'next/router';
 import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './search-form.module.scss';
 
-export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'>;
+export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'> & {
+  /**
+   * The search page url.
+   */
+  searchPage: string;
+};
 
 /**
  * SearchForm component
  *
  * Render a search form.
  */
-const SearchForm: FC<SearchFormProps> = ({ hideLabel }) => {
+const SearchForm: FC<SearchFormProps> = ({ hideLabel, searchPage }) => {
   const intl = useIntl();
   const fieldLabel = intl.formatMessage({
     defaultMessage: 'Search for:',
@@ -28,10 +34,12 @@ const SearchForm: FC<SearchFormProps> = ({ hideLabel }) => {
     id: 'WMqQrv',
   });
 
+  const router = useRouter();
   const [value, setValue] = useState<string>('');
 
   const submitHandler = () => {
-    return;
+    router.push({ pathname: searchPage, query: { s: value } });
+    setValue('');
   };
 
   return (
diff --git a/src/components/organisms/layout/header.stories.tsx b/src/components/organisms/layout/header.stories.tsx
index c58c344..98d6377 100644
--- a/src/components/organisms/layout/header.stories.tsx
+++ b/src/components/organisms/layout/header.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import HeaderComponent from './header';
 
 /**
@@ -10,6 +9,7 @@ export default {
   component: HeaderComponent,
   args: {
     isHome: false,
+    searchPage: '#',
     withLink: false,
   },
   argTypes: {
@@ -95,13 +95,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/organisms/layout/header.test.tsx b/src/components/organisms/layout/header.test.tsx
index 05baaec..a9896f8 100644
--- a/src/components/organisms/layout/header.test.tsx
+++ b/src/components/organisms/layout/header.test.tsx
@@ -14,14 +14,22 @@ const title = 'Assumenda quis quod';
 
 describe('Header', () => {
   it('renders the website title', () => {
-    render(<Header title={title} photo={photo} nav={nav} isHome={true} />);
+    render(
+      <Header
+        searchPage="#"
+        title={title}
+        photo={photo}
+        nav={nav}
+        isHome={true}
+      />
+    );
     expect(
       screen.getByRole('heading', { level: 1, name: title })
     ).toBeInTheDocument();
   });
 
   it('renders the main nav', () => {
-    render(<Header title={title} photo={photo} nav={nav} />);
+    render(<Header searchPage="#" title={title} photo={photo} nav={nav} />);
     expect(screen.getByRole('navigation')).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/layout/header.tsx b/src/components/organisms/layout/header.tsx
index f6ebc9c..18ebb31 100644
--- a/src/components/organisms/layout/header.tsx
+++ b/src/components/organisms/layout/header.tsx
@@ -5,28 +5,25 @@ import { FC } from 'react';
 import Toolbar, { type ToolbarProps } from '../toolbar/toolbar';
 import styles from './header.module.scss';
 
-export type HeaderProps = BrandingProps & {
-  /**
-   * Set additional classnames to the header element.
-   */
-  className?: string;
-  /**
-   * The main nav items.
-   */
-  nav: ToolbarProps['nav'];
-};
+export type HeaderProps = BrandingProps &
+  Pick<ToolbarProps, 'nav' | 'searchPage'> & {
+    /**
+     * Set additional classnames to the header element.
+     */
+    className?: string;
+  };
 
 /**
  * Header component
  *
  * Render the website header.
  */
-const Header: FC<HeaderProps> = ({ className, nav, ...props }) => {
+const Header: FC<HeaderProps> = ({ className, nav, searchPage, ...props }) => {
   return (
     <header className={`${styles.wrapper} ${className}`}>
       <div className={styles.body}>
         <Branding {...props} />
-        <Toolbar nav={nav} className={styles.toolbar} />
+        <Toolbar nav={nav} searchPage={searchPage} className={styles.toolbar} />
       </div>
     </header>
   );
diff --git a/src/components/organisms/modals/search-modal.stories.tsx b/src/components/organisms/modals/search-modal.stories.tsx
index 3ad6abd..f40696c 100644
--- a/src/components/organisms/modals/search-modal.stories.tsx
+++ b/src/components/organisms/modals/search-modal.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SearchModal from './search-modal';
 
 /**
@@ -8,6 +7,9 @@ import SearchModal from './search-modal';
 export default {
   title: 'Organisms/Modals',
   component: SearchModal,
+  args: {
+    searchPage: '#',
+  },
   argTypes: {
     className: {
       control: {
@@ -23,13 +25,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SearchModal>;
 
 const Template: ComponentStory<typeof SearchModal> = (args) => (
diff --git a/src/components/organisms/modals/search-modal.test.tsx b/src/components/organisms/modals/search-modal.test.tsx
index 249c523..7ba08c0 100644
--- a/src/components/organisms/modals/search-modal.test.tsx
+++ b/src/components/organisms/modals/search-modal.test.tsx
@@ -3,7 +3,7 @@ import SearchModal from './search-modal';
 
 describe('SearchModal', () => {
   it('renders a search modal', () => {
-    render(<SearchModal />);
+    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
index 0e0ceed..866bc25 100644
--- a/src/components/organisms/modals/search-modal.tsx
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -1,10 +1,10 @@
 import Modal, { type ModalProps } from '@components/molecules/modals/modal';
 import { FC } from 'react';
 import { useIntl } from 'react-intl';
-import SearchForm from '../forms/search-form';
+import SearchForm, { SearchFormProps } from '../forms/search-form';
 import styles from './search-modal.module.scss';
 
-export type SearchModalProps = {
+export type SearchModalProps = Pick<SearchFormProps, 'searchPage'> & {
   /**
    * Set additional classnames to modal wrapper.
    */
@@ -16,7 +16,7 @@ export type SearchModalProps = {
  *
  * Render a search form modal.
  */
-const SearchModal: FC<SearchModalProps> = ({ className }) => {
+const SearchModal: FC<SearchModalProps> = ({ className, searchPage }) => {
   const intl = useIntl();
   const modalTitle = intl.formatMessage({
     defaultMessage: 'Search',
@@ -26,7 +26,7 @@ const SearchModal: FC<SearchModalProps> = ({ className }) => {
 
   return (
     <Modal title={modalTitle} className={`${styles.wrapper} ${className}`}>
-      <SearchForm hideLabel={true} />
+      <SearchForm hideLabel={true} searchPage={searchPage} />
     </Modal>
   );
 };
diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx
index 0421c8c..c6063a0 100644
--- a/src/components/organisms/toolbar/search.stories.tsx
+++ b/src/components/organisms/toolbar/search.stories.tsx
@@ -9,6 +9,9 @@ import Search from './search';
 export default {
   title: 'Organisms/Toolbar/Search',
   component: Search,
+  args: {
+    searchPage: '#',
+  },
   argTypes: {
     className: {
       control: {
@@ -44,13 +47,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof Search>;
 
 const Template: ComponentStory<typeof Search> = ({
diff --git a/src/components/organisms/toolbar/search.test.tsx b/src/components/organisms/toolbar/search.test.tsx
index 0ce09d8..a18b679 100644
--- a/src/components/organisms/toolbar/search.test.tsx
+++ b/src/components/organisms/toolbar/search.test.tsx
@@ -3,17 +3,17 @@ import Search from './search';
 
 describe('Search', () => {
   it('renders a button to open search modal', () => {
-    render(<Search isActive={false} setIsActive={() => null} />);
+    render(<Search searchPage="#" isActive={false} setIsActive={() => null} />);
     expect(screen.getByRole('checkbox')).toHaveAccessibleName('Open search');
   });
 
   it('renders a button to close search modal', () => {
-    render(<Search isActive={true} setIsActive={() => null} />);
+    render(<Search searchPage="#" isActive={true} setIsActive={() => null} />);
     expect(screen.getByRole('checkbox')).toHaveAccessibleName('Close search');
   });
 
   it('renders a search form', () => {
-    render(<Search isActive={true} setIsActive={() => null} />);
+    render(<Search searchPage="#" isActive={true} setIsActive={() => null} />);
     expect(screen.getByRole('searchbox')).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
index 72cd576..a1471ef 100644
--- a/src/components/organisms/toolbar/search.tsx
+++ b/src/components/organisms/toolbar/search.tsx
@@ -16,13 +16,22 @@ export type SearchProps = {
    * 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: FC<SearchProps> = ({ className = '', isActive, setIsActive }) => {
+const Search: FC<SearchProps> = ({
+  className = '',
+  isActive,
+  searchPage,
+  setIsActive,
+}) => {
   const intl = useIntl();
   const label = isActive
     ? intl.formatMessage({
@@ -53,6 +62,7 @@ const Search: FC<SearchProps> = ({ className = '', isActive, setIsActive }) => {
         <MagnifyingGlass />
       </Label>
       <SearchModal
+        searchPage={searchPage}
         className={`${sharedStyles.modal} ${searchStyles.modal} ${className}`}
       />
     </div>
diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx
index 4f9a3de..477cb55 100644
--- a/src/components/organisms/toolbar/toolbar.stories.tsx
+++ b/src/components/organisms/toolbar/toolbar.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import ToolbarComponent from './toolbar';
 
 /**
@@ -8,6 +7,9 @@ import ToolbarComponent from './toolbar';
 export default {
   title: 'Organisms/Toolbar',
   component: ToolbarComponent,
+  args: {
+    searchPage: '#',
+  },
   argTypes: {
     className: {
       control: {
@@ -31,13 +33,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/organisms/toolbar/toolbar.test.tsx b/src/components/organisms/toolbar/toolbar.test.tsx
index 4bfe8a8..05e84ff 100644
--- a/src/components/organisms/toolbar/toolbar.test.tsx
+++ b/src/components/organisms/toolbar/toolbar.test.tsx
@@ -10,7 +10,7 @@ const nav = [
 
 describe('Toolbar', () => {
   it('renders a navigation menu', () => {
-    render(<Toolbar nav={nav} />);
+    render(<Toolbar nav={nav} searchPage="#" />);
     expect(screen.getByRole('navigation')).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
index f1ce530..6593055 100644
--- a/src/components/organisms/toolbar/toolbar.tsx
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -1,10 +1,10 @@
 import { FC, useState } from 'react';
 import MainNav, { type MainNavProps } from '../toolbar/main-nav';
-import Search from '../toolbar/search';
+import Search, { type SearchProps } from '../toolbar/search';
 import Settings from '../toolbar/settings';
 import styles from './toolbar.module.scss';
 
-export type ToolbarProps = {
+export type ToolbarProps = Pick<SearchProps, 'searchPage'> & {
   /**
    * Set additional classnames to the toolbar wrapper.
    */
@@ -20,7 +20,7 @@ export type ToolbarProps = {
  *
  * Render the website toolbar.
  */
-const Toolbar: FC<ToolbarProps> = ({ className = '', nav }) => {
+const Toolbar: FC<ToolbarProps> = ({ className = '', nav, searchPage }) => {
   const [isNavOpened, setIsNavOpened] = useState<boolean>(false);
   const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
   const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false);
@@ -34,6 +34,7 @@ const Toolbar: FC<ToolbarProps> = ({ className = '', nav }) => {
         className={styles.modal}
       />
       <Search
+        searchPage={searchPage}
         isActive={isSearchOpened}
         setIsActive={setIsSearchOpened}
         className={`${styles.modal} ${styles['modal--search']}`}
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index e1be1af..bfb918b 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -171,9 +171,10 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
         baseline={baseline}
         photo={picture}
         nav={mainNav}
+        searchPage="/recherche"
         isHome={isHome}
-        className={styles.header}
         withLink={true}
+        className={styles.header}
       />
       <Main id="main" className={styles.main}>
         <article {...props}>{children}</article>
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 3acf6a9..38fabd5 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,9 +1,7 @@
 import Notice from '@components/atoms/layout/notice';
 import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
-import LinksListWidget, {
-  LinksListItems,
-} from '@components/organisms/widgets/links-list-widget';
+import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
new file mode 100644
index 0000000..bf14861
--- /dev/null
+++ b/src/pages/recherche/index.tsx
@@ -0,0 +1,324 @@
+import Notice from '@components/atoms/layout/notice';
+import Spinner from '@components/atoms/loaders/spinner';
+import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import PageLayout from '@components/templates/page/page-layout';
+import { type EdgesResponse } from '@services/graphql/api';
+import {
+  getArticleFromRawData,
+  getArticles,
+  getTotalArticles,
+} from '@services/graphql/articles';
+import {
+  getThematicsPreview,
+  getTotalThematics,
+} from '@services/graphql/thematics';
+import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type Article, type Meta } from '@ts/types/app';
+import {
+  RawThematicPreview,
+  RawTopicPreview,
+  type RawArticle,
+} from '@ts/types/raw-data';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import {
+  getLinksListItems,
+  getPageLinkFromRawData,
+} from '@utils/helpers/pages';
+import useDataFromAPI from '@utils/hooks/use-data-from-api';
+import usePagination from '@utils/hooks/use-pagination';
+import useSettings from '@utils/hooks/use-settings';
+import { GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { useIntl } from 'react-intl';
+import { Blog, Graph, WebPage } from 'schema-dts';
+
+type SearchPageProps = {
+  thematicsList: RawThematicPreview[];
+  topicsList: RawTopicPreview[];
+  translation: Messages;
+};
+
+/**
+ * Search page.
+ */
+const SearchPage: NextPage<SearchPageProps> = ({
+  thematicsList,
+  topicsList,
+}) => {
+  const intl = useIntl();
+  const { asPath, query } = useRouter();
+  const title = query.s
+    ? intl.formatMessage(
+        {
+          defaultMessage: 'Search results for {query}',
+          description: 'SearchPage: SEO - Page title',
+          id: 'ZNBhDP',
+        },
+        { query: query.s as string }
+      )
+    : intl.formatMessage({
+        defaultMessage: 'Search',
+        description: 'SearchPage: SEO - Page title',
+        id: 'WDwNDl',
+      });
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const blogLabel = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'Breadcrumb: blog label',
+    id: 'Es52wh',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'blog', name: blogLabel, url: '/blog' },
+    { id: 'search', name: title, url: '/recherche' },
+  ];
+
+  const { blog, website } = useSettings();
+  const pageTitle = `${title} - ${website.name}`;
+  const pageDescription = query.s
+    ? intl.formatMessage(
+        {
+          defaultMessage:
+            'Discover search results for {query} on {websiteName}.',
+          description: 'SearchPage: SEO - Meta description',
+          id: 'pg26sn',
+        },
+        { query: query.s as string, websiteName: website.name }
+      )
+    : intl.formatMessage(
+        {
+          defaultMessage: 'Search for a post on {websiteName}.',
+          description: 'SearchPage: SEO - Meta description',
+          id: 'npisb3',
+        },
+        { websiteName: website.name }
+      );
+  const pageUrl = `${website.url}${asPath}`;
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    name: pageTitle,
+    description: pageDescription,
+    inLanguage: website.locales.default,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${website.url}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const blogSchema: Blog = {
+    '@id': `${website.url}/#blog`,
+    '@type': 'Blog',
+    author: { '@id': `${website.url}/#branding` },
+    creator: { '@id': `${website.url}/#branding` },
+    editor: { '@id': `${website.url}/#branding` },
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, blogSchema],
+  };
+
+  const {
+    data,
+    error,
+    isLoadingInitialData,
+    isLoadingMore,
+    hasNextPage,
+    setSize,
+  } = usePagination<RawArticle>({
+    fallbackData: [],
+    fetcher: getArticles,
+    perPage: blog.postsPerPage,
+    search: query.s as string,
+  });
+
+  const totalArticles = useDataFromAPI<number>(() =>
+    getTotalArticles(query.s as string)
+  );
+
+  const postsCount = intl.formatMessage(
+    {
+      defaultMessage:
+        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
+      id: 'LtsVOx',
+      description: 'SearchPage: posts count meta',
+    },
+    { postsCount: totalArticles || 0 }
+  );
+
+  /**
+   * Retrieve the formatted meta.
+   *
+   * @param {Meta<'article'>} meta - The article meta.
+   * @returns {Post['meta']} The formatted meta.
+   */
+  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
+    const { commentsCount, dates, thematics, wordsCount } = meta;
+
+    return {
+      commentsCount,
+      dates,
+      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
+      thematics: thematics?.map((thematic) => {
+        return { ...thematic, url: `/thematique/${thematic.slug}` };
+      }),
+    };
+  };
+
+  /**
+   * Retrieve the formatted posts.
+   *
+   * @param {Article[]} posts - An array of articles.
+   * @returns {Post[]} An array of formatted posts.
+   */
+  const getPosts = (posts: Article[]): Post[] => {
+    return posts.map((post) => {
+      return {
+        ...post,
+        cover: post.meta.cover,
+        excerpt: post.intro,
+        meta: getPostMeta(post.meta),
+        url: `/article/${post.slug}`,
+      };
+    });
+  };
+
+  /**
+   * Retrieve the posts list from raw data.
+   *
+   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
+   * @returns {Post[]} An array of posts.
+   */
+  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
+    const articlesList: RawArticle[] = [];
+    rawData.forEach((articleData) =>
+      articleData.edges.forEach((edge) => {
+        articlesList.push(edge.node);
+      })
+    );
+
+    return getPosts(
+      articlesList.map((article) => getArticleFromRawData(article))
+    );
+  };
+
+  /**
+   * Load more posts handler.
+   */
+  const loadMore = () => {
+    setSize((prevSize) => prevSize + 1);
+  };
+
+  const thematicsListTitle = intl.formatMessage({
+    defaultMessage: 'Thematics',
+    description: 'SearchPage: thematics list widget title',
+    id: 'Dq6+WH',
+  });
+
+  const topicsListTitle = intl.formatMessage({
+    defaultMessage: 'Topics',
+    description: 'SearchPage: topics list widget title',
+    id: 'N804XO',
+  });
+
+  return (
+    <>
+      <Head>
+        <title>{pageTitle}</title>
+        <meta name="description" content={pageDescription} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="website" />
+        <meta property="og:title" content={title} />
+        <meta property="og:description" content={pageDescription} />
+      </Head>
+      <Script
+        id="schema-blog"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        title={title}
+        breadcrumb={breadcrumb}
+        headerMeta={{ total: postsCount }}
+        widgets={[
+          <LinksListWidget
+            key="thematics-list"
+            items={getLinksListItems(
+              thematicsList.map(getPageLinkFromRawData),
+              'thematic'
+            )}
+            title={thematicsListTitle}
+            level={2}
+          />,
+          <LinksListWidget
+            key="topics-list"
+            items={getLinksListItems(
+              topicsList.map(getPageLinkFromRawData),
+              'topic'
+            )}
+            title={topicsListTitle}
+            level={2}
+          />,
+        ]}
+      >
+        {data && data.length > 0 ? (
+          <PostsList
+            byYear={true}
+            isLoading={isLoadingMore || isLoadingInitialData}
+            loadMore={loadMore}
+            posts={getPostsList(data)}
+            showLoadMoreBtn={hasNextPage}
+            total={totalArticles || 0}
+          />
+        ) : (
+          <Spinner />
+        )}
+        {error && (
+          <Notice
+            kind="error"
+            message={intl.formatMessage({
+              defaultMessage: 'Failed to load.',
+              description: 'SearchPage: failed to load text',
+              id: 'fOe8rH',
+            })}
+          />
+        )}
+      </PageLayout>
+    </>
+  );
+};
+
+export const getStaticProps: GetStaticProps<SearchPageProps> = async ({
+  locale,
+}) => {
+  const totalThematics = await getTotalThematics();
+  const thematics = await getThematicsPreview({ first: totalThematics });
+  const totalTopics = await getTotalTopics();
+  const topics = await getTopicsPreview({ first: totalTopics });
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      thematicsList: thematics.edges.map((edge) => edge.node),
+      topicsList: topics.edges.map((edge) => edge.node),
+      translation,
+    },
+  };
+};
+
+export default SearchPage;
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts
index 5bed9f9..171ab23 100644
--- a/src/services/graphql/api.ts
+++ b/src/services/graphql/api.ts
@@ -48,7 +48,7 @@ export type ArticlesResponse<T> = {
 };
 
 export type CommentsResponse<T> = {
-  comments: T[];
+  comments: T;
 };
 
 export type SendMailResponse<T> = {
@@ -147,7 +147,14 @@ export type ByContentIdVar = {
   contentId: number;
 };
 
-export type sendMailVars = {
+export type SearchVar = {
+  /**
+   * A search term.
+   */
+  search?: string;
+};
+
+export type SendMailVars = {
   body: string;
   clientMutationId: string;
   replyTo: string;
@@ -160,14 +167,14 @@ export type VariablesMap = {
   [articlesQuery]: EdgesVars;
   [articlesSlugQuery]: EdgesVars;
   [commentsQuery]: ByContentIdVar;
-  [sendMailMutation]: sendMailVars;
+  [sendMailMutation]: SendMailVars;
   [thematicBySlugQuery]: BySlugVar;
   [thematicsListQuery]: EdgesVars;
   [thematicsSlugQuery]: EdgesVars;
   [topicBySlugQuery]: BySlugVar;
   [topicsListQuery]: EdgesVars;
   [topicsSlugQuery]: EdgesVars;
-  [totalArticlesQuery]: null;
+  [totalArticlesQuery]: SearchVar;
   [totalThematicsQuery]: null;
   [totalTopicsQuery]: null;
 };
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index e62835d..d65bc9e 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -166,8 +166,8 @@ export const articlesSlugQuery = `query ArticlesSlug($first: Int = 10, $after: S
 /**
  * Query the total number of articles.
  */
-export const totalArticlesQuery = `query PostsTotal {
-  posts {
+export const totalArticlesQuery = `query PostsTotal($search: String = "") {
+  posts(where: {search: $search}) {
     pageInfo {
       total
     }
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index 1eb112e..41052c4 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -21,10 +21,11 @@ import {
  *
  * @returns {Promise<number>} - The articles total number.
  */
-export const getTotalArticles = async (): Promise<number> => {
+export const getTotalArticles = async (search?: string): Promise<number> => {
   const response = await fetchAPI<TotalItems, typeof totalArticlesQuery>({
     api: getAPIUrl(),
     query: totalArticlesQuery,
+    variables: { search },
   });
 
   return response.posts.pageInfo.total;
diff --git a/src/services/graphql/contact.ts b/src/services/graphql/contact.ts
index fca718f..00c6ca2 100644
--- a/src/services/graphql/contact.ts
+++ b/src/services/graphql/contact.ts
@@ -1,4 +1,4 @@
-import { fetchAPI, getAPIUrl, sendMailVars } from './api';
+import { fetchAPI, getAPIUrl, SendMailVars } from './api';
 import { sendMailMutation } from './contact.mutation';
 
 export type SentEmail = {
@@ -15,7 +15,7 @@ export type SentEmail = {
  * @param {sendMailVars} data - The mail data.
  * @returns {Promise<SentEmail>} The mutation response.
  */
-export const sendMail = async (data: sendMailVars): Promise<SentEmail> => {
+export const sendMail = async (data: SendMailVars): Promise<SentEmail> => {
   const response = await fetchAPI<SentEmail, typeof sendMailMutation>({
     api: getAPIUrl(),
     query: sendMailMutation,
diff --git a/src/utils/hooks/use-data-from-api.tsx b/src/utils/hooks/use-data-from-api.tsx
new file mode 100644
index 0000000..7082941
--- /dev/null
+++ b/src/utils/hooks/use-data-from-api.tsx
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Fetch data from an API.
+ *
+ * This hook is a wrapper to `setState` + `useEffect`.
+ *
+ * @param fetcher - A function to fetch data from API.
+ * @returns {T | undefined} The requested data.
+ */
+const useDataFromAPI = <T extends unknown>(
+  fetcher: () => Promise<T>
+): T | undefined => {
+  const [data, setData] = useState<T>();
+
+  useEffect(() => {
+    fetcher().then((apiData) => setData(apiData));
+  }, [fetcher]);
+
+  return data;
+};
+
+export default useDataFromAPI;
diff --git a/src/utils/hooks/use-pagination.tsx b/src/utils/hooks/use-pagination.tsx
index 1e24b75..a80a539 100644
--- a/src/utils/hooks/use-pagination.tsx
+++ b/src/utils/hooks/use-pagination.tsx
@@ -99,7 +99,8 @@ const usePagination = <T extends object>({
     isLoadingInitialData ||
     (size > 0 && data && typeof data[size - 1] === 'undefined');
   const isRefreshing = isValidating && data && data.length === size;
-  const hasNextPage = data && data[data.length - 1].pageInfo.hasNextPage;
+  const hasNextPage =
+    data && data.length > 0 && data[data.length - 1].pageInfo.hasNextPage;
 
   return {
     data,
-- 
cgit v1.2.3


From 2155550fa36a3bc3c8f66e0926530123b4018cd4 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 16 May 2022 12:46:38 +0200
Subject: refactor: use custom hook for breadcrumb items and schema

---
 src/components/templates/layout/layout.stories.tsx |  54 ++++++++---
 src/components/templates/layout/layout.test.tsx    |  12 ++-
 src/components/templates/layout/layout.tsx         |  26 ++++-
 .../templates/page/page-layout.stories.tsx         |  12 +++
 src/components/templates/page/page-layout.test.tsx |  41 ++++++--
 src/components/templates/page/page-layout.tsx      |  11 ++-
 .../sectioned/sectioned-layout.stories.tsx         |  24 +++--
 .../templates/sectioned/sectioned-layout.test.tsx  |   9 +-
 .../templates/sectioned/sectioned-layout.tsx       |   8 +-
 src/pages/404.tsx                                  |  16 ++--
 src/pages/article/[slug].tsx                       |  24 ++---
 src/pages/blog/index.tsx                           |  16 ++--
 src/pages/contact.tsx                              |  22 ++---
 src/pages/cv.tsx                                   |  22 ++---
 src/pages/index.tsx                                |   9 +-
 src/pages/mentions-legales.tsx                     |  22 ++---
 src/pages/projets/[slug].tsx                       |  27 ++----
 src/pages/projets/index.tsx                        |  22 ++---
 src/pages/recherche/index.tsx                      |  22 ++---
 src/pages/sujet/[slug].tsx                         |  22 ++---
 src/pages/thematique/[slug].tsx                    |  22 ++---
 src/utils/hooks/use-breadcrumb.tsx                 | 106 +++++++++++++++++++++
 22 files changed, 343 insertions(+), 206 deletions(-)
 create mode 100644 src/utils/hooks/use-breadcrumb.tsx

(limited to 'src/components/templates/layout/layout.tsx')

diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
index 2415412..105e808 100644
--- a/src/components/templates/layout/layout.stories.tsx
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import LayoutComponent from './layout';
 
 /**
@@ -8,6 +7,10 @@ import LayoutComponent from './layout';
 export default {
   title: 'Templates/LayoutBase',
   component: LayoutComponent,
+  args: {
+    breadcrumbSchema: [],
+    isHome: false,
+  },
   argTypes: {
     children: {
       control: {
@@ -19,6 +22,31 @@ export default {
         required: true,
       },
     },
+    breadcrumbSchema: {
+      control: {
+        type: 'null',
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    isHome: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if it is the homepage.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -35,19 +63,17 @@ export default {
   },
   decorators: [
     (Story) => (
-      <IntlProvider locale="en">
-        <div
-          id="__next"
-          style={{
-            flex: 1,
-            display: 'flex',
-            flexFlow: 'column nowrap',
-            minHeight: '100vh',
-          }}
-        >
-          <Story />
-        </div>
-      </IntlProvider>
+      <div
+        id="__next"
+        style={{
+          flex: 1,
+          display: 'flex',
+          flexFlow: 'column nowrap',
+          minHeight: '100vh',
+        }}
+      >
+        <Story />
+      </div>
     ),
   ],
   parameters: {
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 914e1cd..94145ec 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -1,34 +1,36 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import Layout from './layout';
 
 const body =
   'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 
 describe('Layout', () => {
   it('renders the website header', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('banner')).toBeInTheDocument();
   });
 
   it('renders the website main content', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('main')).toBeInTheDocument();
   });
 
   it('renders the website footer', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('contentinfo')).toBeInTheDocument();
   });
 
   it('renders a skip to content link', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(
       screen.getByRole('link', { name: 'Skip to content' })
     ).toBeInTheDocument();
   });
 
   it('renders an article', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('article')).toHaveTextContent(body);
   });
 });
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index bfb918b..9e9282b 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -13,7 +13,13 @@ import useSettings from '@utils/hooks/use-settings';
 import Script from 'next/script';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
-import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
+import {
+  BreadcrumbList,
+  Person,
+  SearchAction,
+  WebSite,
+  WithContext,
+} from 'schema-dts';
 import styles from './layout.module.scss';
 
 export type QueryAction = SearchAction & {
@@ -21,6 +27,10 @@ export type QueryAction = SearchAction & {
 };
 
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The layout main content.
    */
@@ -36,7 +46,12 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  *
  * Render the base layout used by all pages.
  */
-const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
+const Layout: FC<LayoutProps> = ({
+  breadcrumbSchema,
+  children,
+  isHome,
+  ...props
+}) => {
   const intl = useIntl();
   const { website } = useSettings();
   const { baseline, copyright, locales, name, picture, url } = website;
@@ -153,12 +168,17 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
         id="schema-layout"
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
-      ></Script>
+      />
       <Script
         id="schema-branding"
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
       />
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 8e518aa..002b951 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -16,6 +16,7 @@ export default {
   component: PageLayoutComponent,
   args: {
     allowComments: false,
+    breadcrumbSchema: [],
   },
   argTypes: {
     allowComments: {
@@ -40,6 +41,17 @@ export default {
         value: {},
       },
     },
+    breadcrumbSchema: {
+      control: {
+        type: null,
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
     children: {
       control: {
         type: 'text',
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
index b8fff6a..b3aea8b 100644
--- a/src/components/templates/page/page-layout.test.tsx
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -1,4 +1,5 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import PageLayout from './page-layout';
 
 const title = 'Incidunt ad earum';
@@ -6,13 +7,18 @@ const breadcrumb = [
   { id: 'home', url: '#', name: 'Home' },
   { id: 'page', url: '#', name: title },
 ];
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 const children =
   'Reprehenderit aut quis aperiam magnam quia id. Vero enim animi placeat quia. Laborum sit odio minima. Dolores et debitis eaque iste quidem. Omnis aliquam illum porro ea non. Quaerat totam iste quos ex facilis officia accusantium.';
 
 describe('PageLayout', () => {
   it('renders the page title', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -23,7 +29,11 @@ describe('PageLayout', () => {
 
   it('renders the page content', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -32,7 +42,11 @@ describe('PageLayout', () => {
 
   it('renders the breadcrumb', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -43,7 +57,12 @@ describe('PageLayout', () => {
 
   it('renders the table of contents', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} withToC={true}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        withToC={true}
+      >
         {children}
       </PageLayout>
     );
@@ -54,7 +73,12 @@ describe('PageLayout', () => {
 
   it('renders the comment form', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} allowComments={true}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        allowComments={true}
+      >
         {children}
       </PageLayout>
     );
@@ -79,7 +103,12 @@ describe('PageLayout', () => {
       },
     ];
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} comments={comments}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        comments={comments}
+      >
         {children}
       </PageLayout>
     );
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 4e4ff00..bc90f4c 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -25,7 +25,10 @@ import { useIntl } from 'react-intl';
 import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
 
-export type PageLayoutProps = {
+export type PageLayoutProps = Pick<
+  LayoutProps,
+  'breadcrumbSchema' | 'isHome'
+> & {
   /**
    * True if the page accepts new comments. Default: false.
    */
@@ -58,10 +61,6 @@ export type PageLayoutProps = {
    * The page introduction.
    */
   intro?: PageHeaderProps['intro'];
-  /**
-   * True if it is homepage. Default: false.
-   */
-  isHome?: LayoutProps['isHome'];
   /**
    * The page title.
    */
@@ -85,6 +84,7 @@ const PageLayout: FC<PageLayoutProps> = ({
   children,
   allowComments = false,
   breadcrumb,
+  breadcrumbSchema,
   comments,
   footerMeta,
   headerMeta,
@@ -170,6 +170,7 @@ const PageLayout: FC<PageLayoutProps> = ({
 
   return (
     <Layout
+      breadcrumbSchema={breadcrumbSchema}
       isHome={isHome}
       className={`${styles.article} ${styles[articleModifier]}`}
     >
diff --git a/src/components/templates/sectioned/sectioned-layout.stories.tsx b/src/components/templates/sectioned/sectioned-layout.stories.tsx
index 9ff3b75..ce31a83 100644
--- a/src/components/templates/sectioned/sectioned-layout.stories.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SectionedLayoutComponent from './sectioned-layout';
 
 /**
@@ -8,7 +7,21 @@ import SectionedLayoutComponent from './sectioned-layout';
 export default {
   title: 'Templates/Sectioned',
   component: SectionedLayoutComponent,
+  args: {
+    breadcrumbSchema: [],
+  },
   argTypes: {
+    breadcrumbSchema: {
+      control: {
+        type: null,
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
     sections: {
       description: 'The different sections.',
       type: {
@@ -18,15 +31,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <div id="__next">
-          <Story />
-        </div>
-      </IntlProvider>
-    ),
-  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/templates/sectioned/sectioned-layout.test.tsx b/src/components/templates/sectioned/sectioned-layout.test.tsx
index 334d1cc..9b8bab5 100644
--- a/src/components/templates/sectioned/sectioned-layout.test.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.test.tsx
@@ -1,6 +1,8 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import SectionedLayout from './sectioned-layout';
 
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 const sections = [
   {
     title: 'Section 1',
@@ -26,7 +28,12 @@ const sections = [
 
 describe('SectionedLayout', () => {
   it('renders the correct number of section', () => {
-    render(<SectionedLayout sections={sections} />);
+    render(
+      <SectionedLayout
+        breadcrumbSchema={breadcrumbSchema}
+        sections={sections}
+      />
+    );
     expect(screen.getAllByRole('heading', { name: /^Section/ })).toHaveLength(
       sections.length
     );
diff --git a/src/components/templates/sectioned/sectioned-layout.tsx b/src/components/templates/sectioned/sectioned-layout.tsx
index 36ca039..58d5ad0 100644
--- a/src/components/templates/sectioned/sectioned-layout.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.tsx
@@ -3,11 +3,11 @@ import Section, {
   type SectionVariant,
 } from '@components/atoms/layout/section';
 import { FC } from 'react';
-import Layout from '../layout/layout';
+import Layout, { type LayoutProps } from '../layout/layout';
 
 export type Section = Pick<SectionProps, 'content' | 'title'>;
 
-export type SectionedLayoutProps = {
+export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
   /**
    * An array of objects describing each section.
    */
@@ -19,7 +19,7 @@ export type SectionedLayoutProps = {
  *
  * Render a sectioned layout.
  */
-const SectionedLayout: FC<SectionedLayoutProps> = ({ sections }) => {
+const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
   const getSections = (items: SectionProps[]) => {
     return items.map((section, index) => {
       const variant: SectionVariant = index % 2 ? 'light' : 'dark';
@@ -37,7 +37,7 @@ const SectionedLayout: FC<SectionedLayoutProps> = ({ sections }) => {
     });
   };
 
-  return <Layout>{getSections(sections)}</Layout>;
+  return <Layout {...props}>{getSections(sections)}</Layout>;
 };
 
 export default SectionedLayout;
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 7459c80..4f6e22d 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
 import {
@@ -16,6 +15,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -53,15 +53,10 @@ const Error404Page: NextPage<Error404PageProps> = ({
       link: (chunks: ReactNode) => <Link href="/contact">{chunks}</Link>,
     }
   );
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/404`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'error-404', name: title, url: '/404' },
-  ];
   const pageTitle = intl.formatMessage(
     {
       defaultMessage: 'Error 404: Page not found - {websiteName}',
@@ -95,7 +90,8 @@ const Error404Page: NextPage<Error404PageProps> = ({
       </Head>
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 6a47c16..5eeabd9 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -1,6 +1,5 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import Sharing from '@components/organisms/widgets/sharing';
 import PageLayout, {
   type PageLayoutProps,
@@ -12,13 +11,13 @@ import {
 import { getPostComments } from '@services/graphql/comments';
 import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
-import { useIntl } from 'react-intl';
 import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
@@ -37,22 +36,10 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   const { data } = useSWR(() => id, getPostComments, {
     fallbackData: comments,
   });
-  const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/article/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'article', name: title, url: `/article/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
@@ -186,7 +173,8 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       />
       <PageLayout
         allowComments={true}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         comments={data && getCommentsList(data)}
         footerMeta={footerMeta}
         headerMeta={headerMeta}
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 38fabd5..a5ef045 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,5 +1,4 @@
 import Notice from '@components/atoms/layout/notice';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
@@ -26,6 +25,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
@@ -58,15 +58,10 @@ const BlogPage: NextPage<BlogPageProps> = ({
     description: 'BlogPage: page title',
     id: '7TbbIk',
   });
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: '/blog',
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: title, url: '/blog' },
-  ];
 
   const { blog, website } = useSettings();
   const { asPath } = useRouter();
@@ -234,7 +229,8 @@ const BlogPage: NextPage<BlogPageProps> = ({
       />
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={{ total: postsCount }}
         widgets={[
           <LinksListWidget
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index fcfbe1d..617117b 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -1,7 +1,6 @@
-import Notice, { NoticeKind } from '@components/atoms/layout/notice';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import ContactForm, {
-  ContactFormProps,
+  type ContactFormProps,
 } from '@components/organisms/forms/contact-form';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout from '@components/templates/page/page-layout';
@@ -17,19 +16,15 @@ import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
 import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 
 const ContactPage: NextPage = () => {
   const { dates, intro, seo, title } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/contact`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'contact', name: title, url: '/contact' },
-  ];
 
   const socialMediaTitle = intl.formatMessage({
     defaultMessage: 'Find me elsewhere',
@@ -148,9 +143,10 @@ const ContactPage: NextPage = () => {
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        title="Contact"
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         intro={intro}
-        breadcrumb={breadcrumb}
+        title="Contact"
         widgets={widgets}
       >
         <ContactForm
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 0e4765e..b3dec10 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout, {
@@ -8,6 +7,7 @@ import PageLayout, {
 import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -24,15 +24,10 @@ const CVPage: NextPage = () => {
   const intl = useIntl();
   const { file, image } = data;
   const { dates, intro, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/cv`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'cv', name: title, url: '/cv' },
-  ];
 
   const imageWidgetTitle = intl.formatMessage({
     defaultMessage: 'Others formats',
@@ -148,12 +143,13 @@ const CVPage: NextPage = () => {
 
   return (
     <PageLayout
-      title={title}
-      intro={intro}
+      breadcrumb={breadcrumbItems}
+      breadcrumbSchema={breadcrumbSchema}
       headerMeta={headerMeta}
-      breadcrumb={breadcrumb}
-      withToC={true}
+      intro={intro}
+      title={title}
       widgets={widgets}
+      withToC={true}
     >
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index bc9b572..1143a33 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -19,6 +19,7 @@ import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
 import { ArticleCard } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
@@ -38,6 +39,10 @@ type HomeProps = {
  */
 const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   const intl = useIntl();
+  const { schema: breadcrumbSchema } = useBreadcrumb({
+    title: '',
+    url: `/`,
+  });
 
   /**
    * Retrieve a list of coding links.
@@ -322,7 +327,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   };
 
   return (
-    <Layout isHome={true}>
+    <Layout breadcrumbSchema={breadcrumbSchema} isHome={true}>
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
@@ -340,7 +345,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale }) => {
+export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
   const translation = await loadTranslation(locale);
   const recentPosts = await getArticlesCard({ first: 3 });
 
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 9235e69..41bc218 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -1,35 +1,28 @@
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { useIntl } from 'react-intl';
 import { Article, Graph, WebPage } from 'schema-dts';
 
 /**
  * Legal Notice page.
  */
 const LegalNoticePage: NextPage = () => {
-  const intl = useIntl();
   const { dates, intro, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/mentions-legales`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'legal-notice', name: title, url: '/mentions-legales' },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: {
@@ -93,10 +86,11 @@ const LegalNoticePage: NextPage = () => {
 
   return (
     <PageLayout
-      title={title}
-      intro={intro}
+      breadcrumb={breadcrumbItems}
+      breadcrumbSchema={breadcrumbSchema}
       headerMeta={headerMeta}
-      breadcrumb={breadcrumb}
+      intro={intro}
+      title={title}
       withToC={true}
     >
       <Head>
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 711a5cd..1a90e0f 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -3,7 +3,6 @@ import SocialLink, { SocialWebsite } from '@components/atoms/links/social-link';
 import Spinner from '@components/atoms/loaders/spinner';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Code from '@components/molecules/layout/code';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import Gallery from '@components/organisms/images/gallery';
 import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
@@ -14,6 +13,7 @@ import { ProjectPreview, Repos } from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
 import { capitalize } from '@utils/helpers/strings';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
@@ -37,21 +37,10 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   const { id, intro, meta, title } = project;
   const { cover, dates, license, repos, seo, technologies } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/projets/${id}`,
   });
-  const projectsLabel = intl.formatMessage({
-    defaultMessage: 'Projects',
-    description: 'Breadcrumb: projects label',
-    id: '28GZdv',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'projects', name: projectsLabel, url: '/projets' },
-    { id: 'project', name: title, url: `/projets/${id}` },
-  ];
 
   const ProjectContent: ComponentType<MDXComponents> =
     require(`../../content/projects/${id}.mdx`).default;
@@ -192,7 +181,8 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
       <PageLayout
         title={title}
         intro={intro}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={headerMeta}
         withToC={true}
         widgets={[
@@ -217,7 +207,10 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale, params }) => {
+export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
+  locale,
+  params,
+}) => {
   const translation = await loadTranslation(locale);
   const { slug } = params!;
   const project = await getProjectData(slug as string);
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index a6858c8..4a58269 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import CardsList, {
   CardsListItem,
 } from '@components/organisms/layout/cards-list';
@@ -9,13 +8,13 @@ import styles from '@styles/pages/projects.module.scss';
 import { ProjectCard } from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { useIntl } from 'react-intl';
 import { Article, Graph, WebPage } from 'schema-dts';
 
 type ProjectsPageProps = {
@@ -27,17 +26,11 @@ type ProjectsPageProps = {
  * Projects page.
  */
 const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
-  const intl = useIntl();
   const { dates, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/projets`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'projects', name: title, url: '/projets' },
-  ];
 
   const items: CardsListItem[] = projects.map(
     ({ id, meta: projectMeta, slug, title: projectTitle }) => {
@@ -120,7 +113,8 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
       <PageLayout
         title={title}
         intro={<PageContent components={components} />}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
       >
         <CardsList items={items} titleLevel={2} className={styles.list} />
       </PageLayout>
@@ -128,7 +122,9 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale }) => {
+export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({
+  locale,
+}) => {
   const projects = await getProjectsCard();
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index bf14861..0a7dc60 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -1,6 +1,5 @@
 import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
@@ -26,6 +25,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
@@ -65,21 +65,10 @@ const SearchPage: NextPage<SearchPageProps> = ({
         description: 'SearchPage: SEO - Page title',
         id: 'WDwNDl',
       });
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/recherche`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'search', name: title, url: '/recherche' },
-  ];
 
   const { blog, website } = useSettings();
   const pageTitle = `${title} - ${website.name}`;
@@ -253,7 +242,8 @@ const SearchPage: NextPage<SearchPageProps> = ({
       />
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={{ total: postsCount }}
         widgets={[
           <LinksListWidget
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 22fb531..447d969 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,5 +1,4 @@
 import Heading from '@components/atoms/headings/heading';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -18,6 +17,7 @@ import {
   getPageLinkFromRawData,
   getPostMeta,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -37,21 +37,10 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const { content, intro, meta, slug, title } = currentTopic;
   const { articles, dates, seo, thematics } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/sujet/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'topic', name: title, url: `/sujet/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -149,7 +138,8 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         title={title}
         intro={intro}
         headerMeta={headerMeta}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index dd7e80d..13ef0da 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,5 +1,4 @@
 import Heading from '@components/atoms/headings/heading';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -18,6 +17,7 @@ import {
   getPageLinkFromRawData,
   getPostMeta,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -40,21 +40,10 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
   const { content, intro, meta, slug, title } = currentThematic;
   const { articles, dates, seo, topics } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/thematique/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'thematic', name: title, url: `/thematique/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -152,7 +141,8 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         title={title}
         intro={intro}
         headerMeta={headerMeta}
diff --git a/src/utils/hooks/use-breadcrumb.tsx b/src/utils/hooks/use-breadcrumb.tsx
new file mode 100644
index 0000000..087d400
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumb.tsx
@@ -0,0 +1,106 @@
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import { slugify } from '@utils/helpers/strings';
+import { useIntl } from 'react-intl';
+import { BreadcrumbList } from 'schema-dts';
+import useSettings from './use-settings';
+
+export type useBreadcrumbProps = {
+  /**
+   * The current page title.
+   */
+  title: string;
+  /**
+   * The current page url.
+   */
+  url: string;
+};
+
+export type useBreadcrumbReturn = {
+  /**
+   * The breadcrumb items.
+   */
+  items: BreadcrumbItem[];
+  /**
+   * The breadcrumb JSON schema.
+   */
+  schema: BreadcrumbList['itemListElement'][];
+};
+
+/**
+ * Retrieve the breadcrumb items.
+ *
+ * @param {useBreadcrumbProps} props - An object (the current page title & url).
+ * @returns {useBreadcrumbReturn} The breadcrumb items and its JSON schema.
+ */
+const useBreadcrumb = ({
+  title,
+  url,
+}: useBreadcrumbProps): useBreadcrumbReturn => {
+  const intl = useIntl();
+  const { website } = useSettings();
+  const isArticle = url.startsWith('/article/');
+  const isHome = url === '/';
+  const isProject = url.startsWith('/projets/');
+  const isSearch = url.startsWith('/recherche');
+  const isThematic = url.startsWith('/thematique/');
+  const isTopic = url.startsWith('/sujet/');
+
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const items: BreadcrumbItem[] = [{ id: 'home', name: homeLabel, url: '/' }];
+  const schema: BreadcrumbList['itemListElement'][] = [
+    {
+      '@type': 'ListItem',
+      position: 1,
+      name: homeLabel,
+      item: website.url,
+    },
+  ];
+
+  if (isHome) return { items, schema };
+
+  if (isArticle || isSearch || isThematic || isTopic) {
+    const blogLabel = intl.formatMessage({
+      defaultMessage: 'Blog',
+      description: 'Breadcrumb: blog label',
+      id: 'Es52wh',
+    });
+    items.push({ id: 'blog', name: blogLabel, url: '/blog' });
+    schema.push({
+      '@type': 'ListItem',
+      position: 2,
+      name: blogLabel,
+      item: `${website.url}/blog`,
+    });
+  }
+
+  if (isProject) {
+    const projectsLabel = intl.formatMessage({
+      defaultMessage: 'Projects',
+      description: 'Breadcrumb: projects label',
+      id: '28GZdv',
+    });
+    items.push({ id: 'blog', name: projectsLabel, url: '/projets' });
+    schema.push({
+      '@type': 'ListItem',
+      position: 2,
+      name: projectsLabel,
+      item: `${website.url}/projets`,
+    });
+  }
+
+  items.push({ id: slugify(title), name: title, url });
+  schema.push({
+    '@type': 'ListItem',
+    position: schema.length + 1,
+    name: title,
+    item: `${website.url}${url}`,
+  });
+
+  return { items, schema };
+};
+
+export default useBreadcrumb;
-- 
cgit v1.2.3


From 9a186b357b32325cf77fdce39a6c6c9aff76d8a5 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 17 May 2022 18:31:21 +0200
Subject: chore: listen scroll to hide/show back to top button

---
 .../molecules/buttons/back-to-top.module.scss       |  4 ++--
 src/components/organisms/layout/footer.stories.tsx  | 21 +++++++++++++--------
 src/components/organisms/layout/footer.tsx          | 21 ++++++++++++++++++---
 src/components/templates/layout/layout.module.scss  | 14 ++++++++++++++
 src/components/templates/layout/layout.tsx          | 19 +++++++++++++++++--
 src/utils/hooks/use-scroll-position.tsx             | 15 +++++++++++++++
 6 files changed, 79 insertions(+), 15 deletions(-)
 create mode 100644 src/utils/hooks/use-scroll-position.tsx

(limited to 'src/components/templates/layout/layout.tsx')

diff --git a/src/components/molecules/buttons/back-to-top.module.scss b/src/components/molecules/buttons/back-to-top.module.scss
index 9721bff..77ee97b 100644
--- a/src/components/molecules/buttons/back-to-top.module.scss
+++ b/src/components/molecules/buttons/back-to-top.module.scss
@@ -2,8 +2,8 @@
 
 .wrapper {
   .link {
-    width: clamp(#{fun.convert-px(44)}, 6vw, #{fun.convert-px(55)});
-    height: clamp(#{fun.convert-px(44)}, 6vw, #{fun.convert-px(55)});
+    width: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)});
+    height: clamp(#{fun.convert-px(48)}, 8vw, #{fun.convert-px(55)});
 
     svg {
       width: 100%;
diff --git a/src/components/organisms/layout/footer.stories.tsx b/src/components/organisms/layout/footer.stories.tsx
index 06819da..bd5a744 100644
--- a/src/components/organisms/layout/footer.stories.tsx
+++ b/src/components/organisms/layout/footer.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import FooterComponent from './footer';
 
 /**
@@ -9,6 +8,19 @@ 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',
@@ -53,13 +65,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof FooterComponent>;
 
 const Template: ComponentStory<typeof FooterComponent> = (args) => (
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
index 1426e96..c60afec 100644
--- a/src/components/organisms/layout/footer.tsx
+++ b/src/components/organisms/layout/footer.tsx
@@ -1,13 +1,19 @@
 import Copyright, {
   type CopyrightProps,
 } from '@components/atoms/layout/copyright';
-import BackToTop from '@components/molecules/buttons/back-to-top';
+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.
    */
@@ -31,7 +37,13 @@ export type FooterProps = {
  *
  * Renders a footer with copyright and nav;
  */
-const Footer: FC<FooterProps> = ({ className, copyright, navItems, topId }) => {
+const Footer: FC<FooterProps> = ({
+  backToTopClassName,
+  className = '',
+  copyright,
+  navItems,
+  topId,
+}) => {
   const intl = useIntl();
   const ariaLabel = intl.formatMessage({
     defaultMessage: 'Footer',
@@ -54,7 +66,10 @@ const Footer: FC<FooterProps> = ({ className, copyright, navItems, topId }) => {
           className={styles.nav}
         />
       )}
-      <BackToTop target={topId} className={styles['back-to-top']} />
+      <BackToTop
+        target={topId}
+        className={`${styles['back-to-top']} ${backToTopClassName}`}
+      />
     </footer>
   );
 };
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 806d2d7..09904dc 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -13,6 +13,20 @@
   border-top: fun.convert-px(3) solid var(--color-border-light);
 }
 
+.back-to-top {
+  &--hidden {
+    opacity: 0;
+    transform: translateY(calc(var(--button-size) + var(--spacing-md)));
+    visibility: hidden;
+  }
+
+  &--visible {
+    opacity: 1;
+    transform: translateY(0);
+    visibility: visible;
+  }
+}
+
 .noscript-spacing {
   width: 100%;
   height: fun.convert-px(75);
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 9e9282b..3eb02ee 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -7,11 +7,12 @@ import Home from '@components/atoms/icons/home';
 import PostsStack from '@components/atoms/icons/posts-stack';
 import Main from '@components/atoms/layout/main';
 import NoScript from '@components/atoms/layout/no-script';
-import Footer, { FooterProps } from '@components/organisms/layout/footer';
+import Footer, { type FooterProps } from '@components/organisms/layout/footer';
 import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import useScrollPosition from '@utils/hooks/use-scroll-position';
 import useSettings from '@utils/hooks/use-settings';
 import Script from 'next/script';
-import { FC, ReactNode } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import {
   BreadcrumbList,
@@ -162,6 +163,19 @@ const Layout: FC<LayoutProps> = ({
     subjectOf: { '@id': `${url}` },
   };
 
+  const [backToTopClassName, setBackToTopClassName] = useState<string>(
+    styles['back-to-top--hidden']
+  );
+  const updateBackToTopClassName = () => {
+    setBackToTopClassName(
+      window.scrollY > 300
+        ? styles['back-to-top--visible']
+        : styles['back-to-top--hidden']
+    );
+  };
+
+  useScrollPosition(updateBackToTopClassName);
+
   return (
     <>
       <Script
@@ -203,6 +217,7 @@ const Layout: FC<LayoutProps> = ({
         copyright={copyrightData}
         navItems={footerNav}
         topId="top"
+        backToTopClassName={backToTopClassName}
         className={styles.footer}
       />
       <noscript>
diff --git a/src/utils/hooks/use-scroll-position.tsx b/src/utils/hooks/use-scroll-position.tsx
new file mode 100644
index 0000000..47cfdd0
--- /dev/null
+++ b/src/utils/hooks/use-scroll-position.tsx
@@ -0,0 +1,15 @@
+import { useEffect } from 'react';
+
+/**
+ * Execute the given function based on scroll position.
+ *
+ * @param scrollHandler - A callback function.
+ */
+const useScrollPosition = (scrollHandler: () => void) => {
+  useEffect(() => {
+    window.addEventListener('scroll', scrollHandler);
+    return () => window.removeEventListener('scroll', scrollHandler);
+  }, [scrollHandler]);
+};
+
+export default useScrollPosition;
-- 
cgit v1.2.3


From 271ef6debaca7ed9a01829dcef3a37e90a2dff05 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 17 May 2022 22:48:41 +0200
Subject: chore: use persistent layout

It prevents to rerender the common components between pages (header,
footer...).
---
 src/components/templates/layout/layout.module.scss | 14 ++++++
 src/components/templates/layout/layout.test.tsx    | 11 ++---
 src/components/templates/layout/layout.tsx         | 54 +++++++++++++---------
 .../templates/page/page-layout.module.scss         | 11 -----
 .../templates/page/page-layout.stories.tsx         | 24 ++++------
 src/components/templates/page/page-layout.tsx      | 35 +++++++-------
 .../sectioned/sectioned-layout.stories.tsx         |  8 ++++
 .../templates/sectioned/sectioned-layout.tsx       | 25 ++++++++--
 src/pages/404.tsx                                  |  9 +++-
 src/pages/_app.tsx                                 |  7 +--
 src/pages/article/[slug].tsx                       | 16 +++++--
 src/pages/blog/index.tsx                           | 14 ++++--
 src/pages/contact.tsx                              | 11 +++--
 src/pages/cv.tsx                                   |  9 +++-
 src/pages/index.tsx                                | 20 +++++---
 src/pages/mentions-legales.tsx                     |  9 +++-
 src/pages/projets/[slug].tsx                       | 14 ++++--
 src/pages/projets/index.tsx                        | 10 ++--
 src/pages/recherche/index.tsx                      | 14 ++++--
 src/pages/sujet/[slug].tsx                         | 18 ++++++--
 src/pages/thematique/[slug].tsx                    | 15 ++++--
 src/ts/types/app.ts                                | 21 +++++++++
 22 files changed, 253 insertions(+), 116 deletions(-)

(limited to 'src/components/templates/layout/layout.tsx')

diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 09904dc..1080732 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -1,5 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 @use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
 
 .header {
   border-bottom: fun.convert-px(3) solid var(--color-border-light);
@@ -9,6 +10,19 @@
   flex: 1;
 }
 
+.article {
+  &--grid {
+    @extend %grid;
+
+    grid-auto-flow: column dense;
+    align-items: baseline;
+  }
+
+  &--padding {
+    padding-bottom: var(--spacing-lg);
+  }
+}
+
 .footer {
   border-top: fun.convert-px(3) solid var(--color-border-light);
 }
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 94145ec..78547d4 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -4,33 +4,32 @@ import Layout from './layout';
 
 const body =
   'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
-const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 
 describe('Layout', () => {
   it('renders the website header', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('banner')).toBeInTheDocument();
   });
 
   it('renders the website main content', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('main')).toBeInTheDocument();
   });
 
   it('renders the website footer', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('contentinfo')).toBeInTheDocument();
   });
 
   it('renders a skip to content link', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(
       screen.getByRole('link', { name: 'Skip to content' })
     ).toBeInTheDocument();
   });
 
   it('renders an article', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('article')).toHaveTextContent(body);
   });
 });
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 3eb02ee..559eaed 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -9,18 +9,13 @@ import Main from '@components/atoms/layout/main';
 import NoScript from '@components/atoms/layout/no-script';
 import Footer, { type FooterProps } from '@components/organisms/layout/footer';
 import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import { type NextPageWithLayoutOptions } from '@ts/types/app';
 import useScrollPosition from '@utils/hooks/use-scroll-position';
 import useSettings from '@utils/hooks/use-settings';
 import Script from 'next/script';
-import { FC, ReactNode, useState } from 'react';
+import { FC, ReactElement, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
-import {
-  BreadcrumbList,
-  Person,
-  SearchAction,
-  WebSite,
-  WithContext,
-} from 'schema-dts';
+import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
 import styles from './layout.module.scss';
 
 export type QueryAction = SearchAction & {
@@ -28,18 +23,18 @@ export type QueryAction = SearchAction & {
 };
 
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
-  /**
-   * The breadcrumb JSON schema.
-   */
-  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The layout main content.
    */
   children: ReactNode;
   /**
-   * Set additional classnames to the article element.
+   * Determine if article has a comments section.
+   */
+  withExtraPadding?: boolean;
+  /**
+   * Determine if article should use grid. Default: false.
    */
-  className?: string;
+  useGrid?: boolean;
 };
 
 /**
@@ -48,14 +43,16 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  * Render the base layout used by all pages.
  */
 const Layout: FC<LayoutProps> = ({
-  breadcrumbSchema,
   children,
+  withExtraPadding = false,
   isHome,
-  ...props
+  useGrid = false,
 }) => {
   const intl = useIntl();
   const { website } = useSettings();
   const { baseline, copyright, locales, name, picture, url } = website;
+  const articleGridClass = useGrid ? 'article--grid' : '';
+  const articleCommentsClass = withExtraPadding ? 'article--padding' : '';
 
   const skipToContent = intl.formatMessage({
     defaultMessage: 'Skip to content',
@@ -188,11 +185,6 @@ const Layout: FC<LayoutProps> = ({
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
       />
-      <Script
-        id="schema-breadcrumb"
-        type="application/ld+json"
-        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
-      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
@@ -211,7 +203,11 @@ const Layout: FC<LayoutProps> = ({
         className={styles.header}
       />
       <Main id="main" className={styles.main}>
-        <article {...props}>{children}</article>
+        <article
+          className={`${styles[articleGridClass]} ${styles[articleCommentsClass]}`}
+        >
+          {children}
+        </article>
       </Main>
       <Footer
         copyright={copyrightData}
@@ -227,4 +223,18 @@ const Layout: FC<LayoutProps> = ({
   );
 };
 
+/**
+ * Get the global layout.
+ *
+ * @param {ReactElement} page - A page.
+ * @param {boolean} [isHome] - Determine if it is the homepage.
+ * @returns A page wrapped with the global layout.
+ */
+export const getLayout = (
+  page: ReactElement,
+  props: NextPageWithLayoutOptions
+) => {
+  return <Layout {...props}>{page}</Layout>;
+};
+
 export default Layout;
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index c6b4e8d..c7674ae 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -2,17 +2,6 @@
 @use "@styles/abstracts/mixins" as mix;
 @use "@styles/abstracts/placeholders";
 
-.article {
-  @extend %grid;
-
-  grid-auto-flow: column dense;
-  align-items: baseline;
-
-  &--no-comments {
-    padding-bottom: var(--spacing-lg);
-  }
-}
-
 .breadcrumb {
   @extend %grid;
 
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 8af5f98..bec1066 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -1,11 +1,11 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
-import ProgressBar from '@components/atoms/loaders/progress-bar';
 import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import Sharing from '@components/organisms/widgets/sharing';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
 import PageLayoutComponent from './page-layout';
 
 /**
@@ -118,20 +118,6 @@ export default {
         required: false,
       },
     },
-    isHome: {
-      control: {
-        type: 'boolean',
-      },
-      description: 'Determine if the current page is the homepage.',
-      table: {
-        category: 'Options',
-        defaultValue: { summary: false },
-      },
-      type: {
-        name: 'boolean',
-        required: false,
-      },
-    },
     title: {
       control: {
         type: 'text',
@@ -168,6 +154,13 @@ export default {
       },
     },
   },
+  decorators: [
+    (Story) => (
+      <LayoutBase {...LayoutBase.args}>
+        <Story />
+      </LayoutBase>
+    ),
+  ],
   parameters: {
     layout: 'fullscreen',
   },
@@ -477,7 +470,6 @@ Blog.args = {
   children: (
     <>
       <PostsList posts={posts} byYear={true} total={posts.length} />
-      <ProgressBar min={1} max={1} current={1} info="1/1 page loaded." />
     </>
   ),
   widgets: [
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 2ff2084..d171944 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -21,19 +21,20 @@ import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
+import Script from 'next/script';
 import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
-import Layout, { type LayoutProps } from '../layout/layout';
+import { BreadcrumbList } from 'schema-dts';
 import styles from './page-layout.module.scss';
 
-export type PageLayoutProps = Pick<
-  LayoutProps,
-  'breadcrumbSchema' | 'isHome'
-> & {
+export type PageLayoutProps = {
   /**
    * True if the page accepts new comments. Default: false.
    */
   allowComments?: boolean;
+  /**
+   * Set attributes to the page body.
+   */
   bodyAttributes?: HTMLAttributes<HTMLDivElement>;
   /**
    * Set additional classnames to the body wrapper.
@@ -43,6 +44,10 @@ export type PageLayoutProps = Pick<
    * The breadcrumb items.
    */
   breadcrumb: BreadcrumbItem[];
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The main content of the page.
    */
@@ -98,7 +103,6 @@ const PageLayout: FC<PageLayoutProps> = ({
   headerMeta,
   id,
   intro,
-  isHome = false,
   title,
   widgets,
   withToC = false,
@@ -117,13 +121,7 @@ const PageLayout: FC<PageLayoutProps> = ({
 
   const bodyRef = useRef<HTMLDivElement>(null);
   const isMounted = useIsMounted(bodyRef);
-
   const hasComments = Array.isArray(comments) && comments.length > 0;
-  const articleModifier =
-    hasComments || allowComments
-      ? 'article--has-comments'
-      : 'article--no-comments';
-
   const [status, setStatus] = useState<NoticeKind>('info');
   const [statusMessage, setStatusMessage] = useState<string>('');
   const isReplyRef = useRef<boolean>(false);
@@ -186,11 +184,12 @@ const PageLayout: FC<PageLayoutProps> = ({
   };
 
   return (
-    <Layout
-      breadcrumbSchema={breadcrumbSchema}
-      isHome={isHome}
-      className={`${styles.article} ${styles[articleModifier]}`}
-    >
+    <>
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <Breadcrumb
         items={breadcrumb}
         className={styles.breadcrumb}
@@ -291,7 +290,7 @@ const PageLayout: FC<PageLayoutProps> = ({
           </section>
         </div>
       )}
-    </Layout>
+    </>
   );
 };
 
diff --git a/src/components/templates/sectioned/sectioned-layout.stories.tsx b/src/components/templates/sectioned/sectioned-layout.stories.tsx
index ce31a83..689f9a7 100644
--- a/src/components/templates/sectioned/sectioned-layout.stories.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.stories.tsx
@@ -1,4 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
 import SectionedLayoutComponent from './sectioned-layout';
 
 /**
@@ -31,6 +32,13 @@ export default {
       },
     },
   },
+  decorators: [
+    (Story) => (
+      <LayoutBase {...LayoutBase.args}>
+        <Story />
+      </LayoutBase>
+    ),
+  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/templates/sectioned/sectioned-layout.tsx b/src/components/templates/sectioned/sectioned-layout.tsx
index 58d5ad0..f91c354 100644
--- a/src/components/templates/sectioned/sectioned-layout.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.tsx
@@ -2,12 +2,17 @@ import Section, {
   type SectionProps,
   type SectionVariant,
 } from '@components/atoms/layout/section';
+import Script from 'next/script';
 import { FC } from 'react';
-import Layout, { type LayoutProps } from '../layout/layout';
+import { BreadcrumbList } from 'schema-dts';
 
 export type Section = Pick<SectionProps, 'content' | 'title'>;
 
-export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
+export type SectionedLayoutProps = {
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * An array of objects describing each section.
    */
@@ -19,7 +24,10 @@ export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
  *
  * Render a sectioned layout.
  */
-const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
+const SectionedLayout: FC<SectionedLayoutProps> = ({
+  breadcrumbSchema,
+  sections,
+}) => {
   const getSections = (items: SectionProps[]) => {
     return items.map((section, index) => {
       const variant: SectionVariant = index % 2 ? 'light' : 'dark';
@@ -37,7 +45,16 @@ const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
     });
   };
 
-  return <Layout {...props}>{getSections(sections)}</Layout>;
+  return (
+    <>
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
+      {getSections(sections)}
+    </>
+  );
 };
 
 export default SectionedLayout;
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 4f6e22d..4ab7784 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,11 +1,13 @@
 import Link from '@components/atoms/links/link';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
   type RawThematicPreview,
   type RawTopicPreview,
@@ -17,7 +19,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
@@ -31,7 +33,7 @@ type Error404PageProps = {
 /**
  * Error 404 page.
  */
-const Error404Page: NextPage<Error404PageProps> = ({
+const Error404Page: NextPageWithLayout<Error404PageProps> = ({
   thematicsList,
   topicsList,
 }) => {
@@ -119,6 +121,9 @@ const Error404Page: NextPage<Error404PageProps> = ({
   );
 };
 
+Error404Page.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<Error404PageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 939b337..5bc9f85 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -1,15 +1,16 @@
+import { type AppPropsWithLayout } from '@ts/types/app';
 import { settings } from '@utils/config';
 import { AckeeProvider } from '@utils/providers/ackee';
 import { PrismThemeProvider } from '@utils/providers/prism-theme';
 import { ThemeProvider } from 'next-themes';
-import { AppProps } from 'next/app';
 import { useRouter } from 'next/router';
 import { IntlProvider } from 'react-intl';
 import '../styles/globals.scss';
 
-const App = ({ Component, pageProps }: AppProps) => {
+const App = ({ Component, pageProps }: AppPropsWithLayout) => {
   const { locale, defaultLocale } = useRouter();
   const appLocale: string = locale || settings.locales.defaultLocale;
+  const getLayout = Component.getLayout ?? ((page) => page);
 
   return (
     <AckeeProvider domain={settings.ackee.url} siteId={settings.ackee.siteId}>
@@ -24,7 +25,7 @@ const App = ({ Component, pageProps }: AppProps) => {
           enableSystem={true}
         >
           <PrismThemeProvider>
-            <Component {...pageProps} />
+            {getLayout(<Component {...pageProps} />, {})}
           </PrismThemeProvider>
         </ThemeProvider>
       </IntlProvider>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index a0fb7fc..a3df43b 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -2,6 +2,7 @@ import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Sharing from '@components/organisms/widgets/sharing';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -11,7 +12,11 @@ import {
 } from '@services/graphql/articles';
 import { getPostComments } from '@services/graphql/comments';
 import styles from '@styles/pages/article.module.scss';
-import { type Article, type Comment } from '@ts/types/app';
+import {
+  type Article,
+  type Comment,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
@@ -20,7 +25,7 @@ import usePrismPlugins, {
 } from '@utils/hooks/use-prism-plugins';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -39,7 +44,10 @@ type ArticlePageProps = {
 /**
  * Article page.
  */
-const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
+const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
+  comments,
+  post,
+}) => {
   const { content, id, intro, meta, slug, title } = post;
   const {
     author,
@@ -252,6 +260,8 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   );
 };
 
+ArticlePage.getLayout = (page) => getLayout(page, { useGrid: true });
+
 interface PostParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index b6ce221..2676305 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,6 +1,7 @@
 import Notice from '@components/atoms/layout/notice';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
@@ -13,7 +14,11 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import { type Article, type Meta } from '@ts/types/app';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import {
   RawThematicPreview,
   RawTopicPreview,
@@ -28,7 +33,7 @@ import {
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -46,7 +51,7 @@ type BlogPageProps = {
 /**
  * Blog index page.
  */
-const BlogPage: NextPage<BlogPageProps> = ({
+const BlogPage: NextPageWithLayout<BlogPageProps> = ({
   articles,
   thematicsList,
   topicsList,
@@ -268,6 +273,9 @@ const BlogPage: NextPage<BlogPageProps> = ({
   );
 };
 
+BlogPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index 617117b..c0d6c79 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -3,22 +3,24 @@ import ContactForm, {
   type ContactFormProps,
 } from '@components/organisms/forms/contact-form';
 import SocialMedia from '@components/organisms/widgets/social-media';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { meta } from '@content/pages/contact.mdx';
 import styles from '@styles/pages/contact.module.scss';
 import { sendMail } from '@services/graphql/contact';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
 import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
-import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 
-const ContactPage: NextPage = () => {
+const ContactPage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
   const intl = useIntl();
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
@@ -164,6 +166,9 @@ const ContactPage: NextPage = () => {
   );
 };
 
+ContactPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 7936c84..3f035d8 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -3,16 +3,18 @@ import Link from '@components/atoms/links/link';
 import List from '@components/atoms/lists/list';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -23,7 +25,7 @@ import { AboutPage, Graph, WebPage } from 'schema-dts';
 /**
  * CV page.
  */
-const CVPage: NextPage = () => {
+const CVPage: NextPageWithLayout = () => {
   const intl = useIntl();
   const { file, image } = data;
   const { dates, intro, seo, title } = meta;
@@ -186,6 +188,9 @@ const CVPage: NextPage = () => {
   );
 };
 
+CVPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 1143a33..a831ea3 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -13,16 +13,16 @@ import Columns, {
 import CardsList, {
   type CardsListItem,
 } from '@components/organisms/layout/cards-list';
-import Layout from '@components/templates/layout/layout';
+import { getLayout } from '@components/templates/layout/layout';
 import HomePageContent from '@content/pages/homepage.mdx';
 import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
-import { ArticleCard } from '@ts/types/app';
+import { type ArticleCard, type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import Script from 'next/script';
 import { ReactElement } from 'react';
@@ -37,7 +37,7 @@ type HomeProps = {
 /**
  * Home page.
  */
-const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
+const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
   const intl = useIntl();
   const { schema: breadcrumbSchema } = useBreadcrumb({
     title: '',
@@ -327,7 +327,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   };
 
   return (
-    <Layout breadcrumbSchema={breadcrumbSchema} isHome={true}>
+    <>
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
@@ -340,11 +340,19 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <HomePageContent components={components} />
-    </Layout>
+    </>
   );
 };
 
+HomePage.getLayout = (page) =>
+  getLayout(page, { isHome: true, withExtraPadding: false });
+
 export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
   const translation = await loadTranslation(locale);
   const recentPosts = await getArticlesCard({ first: 3 });
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 41bc218..c8d1772 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -1,14 +1,16 @@
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -17,7 +19,7 @@ import { Article, Graph, WebPage } from 'schema-dts';
 /**
  * Legal Notice page.
  */
-const LegalNoticePage: NextPage = () => {
+const LegalNoticePage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -111,6 +113,9 @@ const LegalNoticePage: NextPage = () => {
   );
 };
 
+LegalNoticePage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 1a90e0f..cf7d0dc 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -6,10 +6,15 @@ import Code from '@components/molecules/layout/code';
 import Gallery from '@components/organisms/images/gallery';
 import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   PageLayoutProps,
 } from '@components/templates/page/page-layout';
-import { ProjectPreview, Repos } from '@ts/types/app';
+import {
+  type NextPageWithLayout,
+  type ProjectPreview,
+  type Repos,
+} from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
 import { capitalize } from '@utils/helpers/strings';
@@ -17,7 +22,7 @@ import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -33,7 +38,7 @@ type ProjectPageProps = {
 /**
  * Project page.
  */
-const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
+const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
   const { id, intro, meta, title } = project;
   const { cover, dates, license, repos, seo, technologies } = meta;
   const intl = useIntl();
@@ -207,6 +212,9 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   );
 };
 
+ProjectPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
   locale,
   params,
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 4a58269..d500b6b 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -2,16 +2,17 @@ import Link from '@components/atoms/links/link';
 import CardsList, {
   CardsListItem,
 } from '@components/organisms/layout/cards-list';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import PageContent, { meta } from '@content/pages/projects.mdx';
 import styles from '@styles/pages/projects.module.scss';
-import { ProjectCard } from '@ts/types/app';
+import { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -25,7 +26,7 @@ type ProjectsPageProps = {
 /**
  * Projects page.
  */
-const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
+const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
   const { dates, seo, title } = meta;
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -122,6 +123,9 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
   );
 };
 
+ProjectsPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index d88a293..09091c8 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -2,6 +2,7 @@ import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
@@ -14,7 +15,11 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import { type Article, type Meta } from '@ts/types/app';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import {
   RawThematicPreview,
   RawTopicPreview,
@@ -29,7 +34,7 @@ import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -45,7 +50,7 @@ type SearchPageProps = {
 /**
  * Search page.
  */
-const SearchPage: NextPage<SearchPageProps> = ({
+const SearchPage: NextPageWithLayout<SearchPageProps> = ({
   thematicsList,
   topicsList,
 }) => {
@@ -283,6 +288,9 @@ const SearchPage: NextPage<SearchPageProps> = ({
   );
 };
 
+SearchPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<SearchPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 348fe05..6277293 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -2,6 +2,7 @@ import Heading from '@components/atoms/headings/heading';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -12,7 +13,12 @@ import {
   getTotalTopics,
 } from '@services/graphql/topics';
 import styles from '@styles/pages/topic.module.scss';
-import { type Article, type PageLink, type Topic } from '@ts/types/app';
+import {
+  type Article,
+  type NextPageWithLayout,
+  type PageLink,
+  type Topic,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
@@ -21,7 +27,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -35,7 +41,10 @@ export type TopicPageProps = {
   translation: Messages;
 };
 
-const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
+const TopicPage: NextPageWithLayout<TopicPageProps> = ({
+  currentTopic,
+  topics,
+}) => {
   const { content, intro, meta, slug, title } = currentTopic;
   const {
     articles,
@@ -208,6 +217,9 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   );
 };
 
+TopicPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 interface TopicParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 13ef0da..23e6a8b 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,6 +1,7 @@
 import Heading from '@components/atoms/headings/heading';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -10,7 +11,12 @@ import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
-import { type Article, type PageLink, type Thematic } from '@ts/types/app';
+import {
+  type Article,
+  type NextPageWithLayout,
+  type PageLink,
+  type Thematic,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
@@ -19,7 +25,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -33,7 +39,7 @@ export type ThematicPageProps = {
   translation: Messages;
 };
 
-const ThematicPage: NextPage<ThematicPageProps> = ({
+const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
   currentThematic,
   thematics,
 }) => {
@@ -191,6 +197,9 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
   );
 };
 
+ThematicPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 interface ThematicParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index a3b9889..feff5a5 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -1,3 +1,24 @@
+import { NextPage } from 'next';
+import { AppProps } from 'next/app';
+import { ReactElement, ReactNode } from 'react';
+
+export type NextPageWithLayoutOptions = {
+  withExtraPadding?: boolean;
+  isHome?: boolean;
+  useGrid?: boolean;
+};
+
+export type NextPageWithLayout<T = {}> = NextPage<T> & {
+  getLayout?: (
+    page: ReactElement,
+    options: NextPageWithLayoutOptions
+  ) => ReactNode;
+};
+
+export type AppPropsWithLayout = AppProps & {
+  Component: NextPageWithLayout;
+};
+
 export type ContentKind =
   | 'article'
   | 'comment'
-- 
cgit v1.2.3


From 584bd42f871d2e1618ca414749f09c38f0143a44 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Wed, 18 May 2022 22:40:59 +0200
Subject: chore: handle settings change

---
 .../molecules/forms/ackee-select.stories.tsx       | 18 +++++-----
 .../molecules/forms/ackee-select.test.tsx          |  4 +--
 src/components/molecules/forms/ackee-select.tsx    | 26 +++++++++++----
 .../molecules/forms/motion-toggle.stories.tsx      | 18 +++++-----
 .../molecules/forms/motion-toggle.test.tsx         |  2 +-
 src/components/molecules/forms/motion-toggle.tsx   | 34 +++++++++++++++----
 .../molecules/forms/prism-theme-toggle.stories.tsx | 21 ------------
 .../molecules/forms/prism-theme-toggle.test.tsx    |  2 +-
 .../molecules/forms/prism-theme-toggle.tsx         | 34 ++++++++++++++-----
 .../molecules/forms/theme-toggle.stories.tsx       | 21 ------------
 .../molecules/forms/theme-toggle.test.tsx          |  2 +-
 src/components/molecules/forms/theme-toggle.tsx    | 20 +++++++++---
 .../organisms/forms/settings-form.test.tsx         | 38 +++++++++++++++++++---
 src/components/organisms/forms/settings-form.tsx   | 32 ++++++++++++++----
 src/components/organisms/layout/header.stories.tsx | 22 +++++++++++++
 src/components/organisms/layout/header.test.tsx    | 19 ++++++++---
 src/components/organisms/layout/header.tsx         | 22 +++++++++++--
 src/components/organisms/modals/search-modal.tsx   |  1 +
 .../organisms/modals/settings-modal.test.tsx       |  7 +++-
 src/components/organisms/modals/settings-modal.tsx |  6 +++-
 .../organisms/toolbar/settings.stories.tsx         | 24 ++++++++++++++
 src/components/organisms/toolbar/settings.test.tsx | 18 ++++++++--
 src/components/organisms/toolbar/settings.tsx      | 22 +++++++++----
 .../organisms/toolbar/toolbar-items.module.scss    |  2 +-
 .../organisms/toolbar/toolbar.stories.tsx          | 22 +++++++++++++
 src/components/organisms/toolbar/toolbar.test.tsx  |  9 ++++-
 src/components/organisms/toolbar/toolbar.tsx       | 37 +++++++++++++--------
 src/components/templates/layout/layout.tsx         | 10 +++---
 src/components/templates/page/page-layout.tsx      |  3 ++
 src/services/local-storage/index.ts                |  6 ++--
 src/utils/hooks/use-attributes.tsx                 | 33 +++++++++++++++++++
 src/utils/hooks/use-code-blocks-theme.tsx          | 22 +++++++++++++
 src/utils/hooks/use-local-storage.tsx              | 35 ++++++++++++++++++++
 src/utils/hooks/use-query-selector-all.tsx         | 16 +++++++++
 src/utils/hooks/use-update-ackee-options.tsx       | 19 +++++++++++
 src/utils/providers/prism-theme.tsx                |  2 +-
 36 files changed, 487 insertions(+), 142 deletions(-)
 create mode 100644 src/utils/hooks/use-attributes.tsx
 create mode 100644 src/utils/hooks/use-code-blocks-theme.tsx
 create mode 100644 src/utils/hooks/use-local-storage.tsx
 create mode 100644 src/utils/hooks/use-query-selector-all.tsx
 create mode 100644 src/utils/hooks/use-update-ackee-options.tsx

(limited to 'src/components/templates/layout/layout.tsx')

diff --git a/src/components/molecules/forms/ackee-select.stories.tsx b/src/components/molecules/forms/ackee-select.stories.tsx
index 4e6adf1..6b42b71 100644
--- a/src/components/molecules/forms/ackee-select.stories.tsx
+++ b/src/components/molecules/forms/ackee-select.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import AckeeSelect from './ackee-select';
 
 /**
@@ -33,6 +32,16 @@ export default {
         required: false,
       },
     },
+    storageKey: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set Ackee settings local storage key.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     tooltipClassName: {
       control: {
         type: 'text',
@@ -47,13 +56,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof AckeeSelect>;
 
 const Template: ComponentStory<typeof AckeeSelect> = (args) => (
diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx
index ec27922..0089c06 100644
--- a/src/components/molecules/forms/ackee-select.test.tsx
+++ b/src/components/molecules/forms/ackee-select.test.tsx
@@ -4,13 +4,13 @@ import AckeeSelect from './ackee-select';
 
 describe('Select', () => {
   it('should correctly set default option', () => {
-    render(<AckeeSelect initialValue="full" />);
+    render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />);
     expect(screen.getByRole('combobox')).toHaveValue('full');
     expect(screen.queryByRole('combobox')).not.toHaveValue('partial');
   });
 
   it('should correctly change value when user choose another option', async () => {
-    render(<AckeeSelect initialValue="full" />);
+    render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />);
 
     await act(async () => {
       await user.selectOptions(
diff --git a/src/components/molecules/forms/ackee-select.tsx b/src/components/molecules/forms/ackee-select.tsx
index 101e5b5..34850fb 100644
--- a/src/components/molecules/forms/ackee-select.tsx
+++ b/src/components/molecules/forms/ackee-select.tsx
@@ -1,12 +1,14 @@
-import { SelectOptions } from '@components/atoms/forms/select';
-import { Dispatch, FC, SetStateAction, useState } from 'react';
+import { type SelectOptions } from '@components/atoms/forms/select';
+import useLocalStorage from '@utils/hooks/use-local-storage';
+import useUpdateAckeeOptions, {
+  type AckeeOptions,
+} from '@utils/hooks/use-update-ackee-options';
+import { Dispatch, FC, SetStateAction } from 'react';
 import { useIntl } from 'react-intl';
 import SelectWithTooltip, {
   type SelectWithTooltipProps,
 } from './select-with-tooltip';
 
-export type AckeeOptions = 'full' | 'partial';
-
 export type AckeeSelectProps = Pick<
   SelectWithTooltipProps,
   'labelClassName' | 'tooltipClassName'
@@ -15,6 +17,10 @@ export type AckeeSelectProps = Pick<
    * A default value for Ackee settings.
    */
   initialValue: AckeeOptions;
+  /**
+   * The local storage key to save preference.
+   */
+  storageKey: string;
 };
 
 /**
@@ -22,9 +28,17 @@ export type AckeeSelectProps = Pick<
  *
  * Render a select to set Ackee settings.
  */
-const AckeeSelect: FC<AckeeSelectProps> = ({ initialValue, ...props }) => {
+const AckeeSelect: FC<AckeeSelectProps> = ({
+  initialValue,
+  storageKey,
+  ...props
+}) => {
   const intl = useIntl();
-  const [value, setValue] = useState<AckeeOptions>(initialValue);
+  const { value, setValue } = useLocalStorage<AckeeOptions>(
+    storageKey,
+    initialValue
+  );
+  useUpdateAckeeOptions(value);
 
   const ackeeLabel = intl.formatMessage({
     defaultMessage: 'Tracking:',
diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/molecules/forms/motion-toggle.stories.tsx
index dcfc68d..60430d5 100644
--- a/src/components/molecules/forms/motion-toggle.stories.tsx
+++ b/src/components/molecules/forms/motion-toggle.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import MotionToggleComponent from './motion-toggle';
 
 /**
@@ -22,6 +21,16 @@ export default {
         required: false,
       },
     },
+    storageKey: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set local storage key.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     value: {
       control: {
         type: null,
@@ -33,13 +42,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof MotionToggleComponent>;
 
 const Template: ComponentStory<typeof MotionToggleComponent> = (args) => (
diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/molecules/forms/motion-toggle.test.tsx
index 77bc17c..4fd6b31 100644
--- a/src/components/molecules/forms/motion-toggle.test.tsx
+++ b/src/components/molecules/forms/motion-toggle.test.tsx
@@ -3,7 +3,7 @@ import MotionToggle from './motion-toggle';
 
 describe('MotionToggle', () => {
   it('renders a checked toggle (deactivate animations choice)', () => {
-    render(<MotionToggle value={true} />);
+    render(<MotionToggle storageKey="reduced-motion" value={true} />);
     expect(
       screen.getByRole('checkbox', {
         name: `Animations: On Off`,
diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx
index 24b54ae..e3bb11a 100644
--- a/src/components/molecules/forms/motion-toggle.tsx
+++ b/src/components/molecules/forms/motion-toggle.tsx
@@ -2,19 +2,41 @@ import Toggle, {
   type ToggleChoices,
   type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { FC, useState } from 'react';
+import useAttributes from '@utils/hooks/use-attributes';
+import useLocalStorage from '@utils/hooks/use-local-storage';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 
-export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
+export type MotionToggleProps = Pick<
+  ToggleProps,
+  'labelClassName' | 'value'
+> & {
+  /**
+   * The local storage key to save preference.
+   */
+  storageKey: string;
+};
 
 /**
  * MotionToggle component
  *
  * Render a Toggle component to set reduce motion.
  */
-const MotionToggle: FC<MotionToggleProps> = ({ value, ...props }) => {
+const MotionToggle: FC<MotionToggleProps> = ({
+  storageKey,
+  value,
+  ...props
+}) => {
   const intl = useIntl();
-  const [isDeactivated, setIsDeactivated] = useState<boolean>(value);
+  const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>(
+    storageKey,
+    value
+  );
+  useAttributes({
+    attribute: 'reducedMotion',
+    value: `${isReduced}`,
+  });
+
   const reduceMotionLabel = intl.formatMessage({
     defaultMessage: 'Animations:',
     description: 'MotionToggle: reduce motion label',
@@ -42,8 +64,8 @@ const MotionToggle: FC<MotionToggleProps> = ({ value, ...props }) => {
       label={reduceMotionLabel}
       labelSize="medium"
       choices={reduceMotionChoices}
-      value={isDeactivated}
-      setValue={setIsDeactivated}
+      value={isReduced}
+      setValue={setIsReduced}
       {...props}
     />
   );
diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
index 513ebfc..ef4ed6e 100644
--- a/src/components/molecules/forms/prism-theme-toggle.stories.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import PrismThemeToggle from './prism-theme-toggle';
 
 /**
@@ -22,24 +21,7 @@ export default {
         required: false,
       },
     },
-    value: {
-      control: {
-        type: null,
-      },
-      description: 'The prism theme value.',
-      type: {
-        name: 'boolean',
-        required: true,
-      },
-    },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof PrismThemeToggle>;
 
 const Template: ComponentStory<typeof PrismThemeToggle> = (args) => (
@@ -50,6 +32,3 @@ const Template: ComponentStory<typeof PrismThemeToggle> = (args) => (
  * Toggle Stories - Prism theme
  */
 export const PrismTheme = Template.bind({});
-PrismTheme.args = {
-  value: false,
-};
diff --git a/src/components/molecules/forms/prism-theme-toggle.test.tsx b/src/components/molecules/forms/prism-theme-toggle.test.tsx
index 0dceb92..d831b82 100644
--- a/src/components/molecules/forms/prism-theme-toggle.test.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.test.tsx
@@ -3,7 +3,7 @@ import PrismThemeToggle from './prism-theme-toggle';
 
 describe('PrismThemeToggle', () => {
   it('renders a checked toggle (dark theme choice)', () => {
-    render(<PrismThemeToggle value={true} />);
+    render(<PrismThemeToggle />);
     expect(
       screen.getByRole('checkbox', {
         name: `Code blocks: Light theme Dark theme`,
diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx
index 0b9c447..3320722 100644
--- a/src/components/molecules/forms/prism-theme-toggle.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.tsx
@@ -4,22 +4,38 @@ import Toggle, {
   type ToggleChoices,
   type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { FC, useState } from 'react';
+import { usePrismTheme } from '@utils/providers/prism-theme';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 
-export type PrismThemeToggleProps = Pick<
-  ToggleProps,
-  'labelClassName' | 'value'
->;
+export type PrismThemeToggleProps = Pick<ToggleProps, 'labelClassName'>;
 
 /**
  * PrismThemeToggle component
  *
  * Render a Toggle component to set code blocks theme.
  */
-const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ value, ...props }) => {
+const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => {
   const intl = useIntl();
-  const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
+  const { theme, setTheme, resolvedTheme } = usePrismTheme();
+
+  /**
+   * Check if the resolved or chosen theme is dark theme.
+   *
+   * @returns {boolean} True if it is dark theme.
+   */
+  const isDarkTheme = (): boolean => {
+    if (theme === 'system') return resolvedTheme === 'dark';
+    return theme === 'dark';
+  };
+
+  /**
+   * Update the theme.
+   */
+  const updateTheme = () => {
+    setTheme(isDarkTheme() ? 'light' : 'dark');
+  };
+
   const themeLabel = intl.formatMessage({
     defaultMessage: 'Code blocks:',
     description: 'PrismThemeToggle: theme label',
@@ -47,8 +63,8 @@ const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ value, ...props }) => {
       label={themeLabel}
       labelSize="medium"
       choices={themeChoices}
-      value={isDarkTheme}
-      setValue={setIsDarkTheme}
+      value={isDarkTheme()}
+      setValue={updateTheme}
       {...props}
     />
   );
diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx
index 05d94b9..5ebf5a2 100644
--- a/src/components/molecules/forms/theme-toggle.stories.tsx
+++ b/src/components/molecules/forms/theme-toggle.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import ThemeToggle from './theme-toggle';
 
 /**
@@ -22,24 +21,7 @@ export default {
         required: false,
       },
     },
-    value: {
-      control: {
-        type: null,
-      },
-      description: 'The theme value.',
-      type: {
-        name: 'boolean',
-        required: true,
-      },
-    },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof ThemeToggle>;
 
 const Template: ComponentStory<typeof ThemeToggle> = (args) => (
@@ -50,6 +32,3 @@ const Template: ComponentStory<typeof ThemeToggle> = (args) => (
  * Toggle Stories - Theme
  */
 export const Theme = Template.bind({});
-Theme.args = {
-  value: false,
-};
diff --git a/src/components/molecules/forms/theme-toggle.test.tsx b/src/components/molecules/forms/theme-toggle.test.tsx
index 5cd3209..89b4f6f 100644
--- a/src/components/molecules/forms/theme-toggle.test.tsx
+++ b/src/components/molecules/forms/theme-toggle.test.tsx
@@ -3,7 +3,7 @@ import ThemeToggle from './theme-toggle';
 
 describe('ThemeToggle', () => {
   it('renders a checked toggle (dark theme choice)', () => {
-    render(<ThemeToggle value={true} />);
+    render(<ThemeToggle />);
     expect(
       screen.getByRole('checkbox', {
         name: `Theme: Light theme Dark theme`,
diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx
index 10c6c47..61ee4c6 100644
--- a/src/components/molecules/forms/theme-toggle.tsx
+++ b/src/components/molecules/forms/theme-toggle.tsx
@@ -4,19 +4,29 @@ import Toggle, {
   type ToggleChoices,
   type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { FC, useState } from 'react';
+import { useTheme } from 'next-themes';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 
-export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
+export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName'>;
 
 /**
  * ThemeToggle component
  *
  * Render a Toggle component to set theme.
  */
-const ThemeToggle: FC<ThemeToggleProps> = ({ value, ...props }) => {
+const ThemeToggle: FC<ThemeToggleProps> = ({ ...props }) => {
   const intl = useIntl();
-  const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
+  const { resolvedTheme, setTheme } = useTheme();
+  const isDarkTheme = resolvedTheme === 'dark';
+
+  /**
+   * Update the theme.
+   */
+  const updateTheme = () => {
+    setTheme(isDarkTheme ? 'light' : 'dark');
+  };
+
   const themeLabel = intl.formatMessage({
     defaultMessage: 'Theme:',
     description: 'ThemeToggle: theme label',
@@ -45,7 +55,7 @@ const ThemeToggle: FC<ThemeToggleProps> = ({ value, ...props }) => {
       labelSize="medium"
       choices={themeChoices}
       value={isDarkTheme}
-      setValue={setIsDarkTheme}
+      setValue={updateTheme}
       {...props}
     />
   );
diff --git a/src/components/organisms/forms/settings-form.test.tsx b/src/components/organisms/forms/settings-form.test.tsx
index beb65ec..43d546e 100644
--- a/src/components/organisms/forms/settings-form.test.tsx
+++ b/src/components/organisms/forms/settings-form.test.tsx
@@ -1,37 +1,65 @@
 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 />);
+    render(
+      <SettingsForm
+        ackeeStorageKey={ackeeStorageKey}
+        motionStorageKey={motionStorageKey}
+      />
+    );
     expect(
       screen.getByRole('form', { name: /^Settings form/i })
     ).toBeInTheDocument();
   });
 
   it('renders a theme toggle setting', () => {
-    render(<SettingsForm />);
+    render(
+      <SettingsForm
+        ackeeStorageKey={ackeeStorageKey}
+        motionStorageKey={motionStorageKey}
+      />
+    );
     expect(
       screen.getByRole('checkbox', { name: /^Theme:/i })
     ).toBeInTheDocument();
   });
 
   it('renders a code blocks toggle setting', () => {
-    render(<SettingsForm />);
+    render(
+      <SettingsForm
+        ackeeStorageKey={ackeeStorageKey}
+        motionStorageKey={motionStorageKey}
+      />
+    );
     expect(
       screen.getByRole('checkbox', { name: /^Code blocks:/i })
     ).toBeInTheDocument();
   });
 
   it('renders a motion setting', () => {
-    render(<SettingsForm />);
+    render(
+      <SettingsForm
+        ackeeStorageKey={ackeeStorageKey}
+        motionStorageKey={motionStorageKey}
+      />
+    );
     expect(
       screen.getByRole('checkbox', { name: /^Animations:/i })
     ).toBeInTheDocument();
   });
 
   it('renders a Ackee setting', () => {
-    render(<SettingsForm />);
+    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
index 0a34601..c897fa5 100644
--- a/src/components/organisms/forms/settings-form.tsx
+++ b/src/components/organisms/forms/settings-form.tsx
@@ -2,16 +2,31 @@ import Form from '@components/atoms/forms/form';
 import AckeeSelect, {
   type AckeeSelectProps,
 } from '@components/molecules/forms/ackee-select';
-import MotionToggle from '@components/molecules/forms/motion-toggle';
+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'>;
+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> = ({ tooltipClassName }) => {
+const SettingsForm: FC<SettingsFormProps> = ({
+  ackeeStorageKey,
+  motionStorageKey,
+  tooltipClassName,
+}) => {
   const intl = useIntl();
   const ariaLabel = intl.formatMessage({
     defaultMessage: 'Settings form',
@@ -21,13 +36,18 @@ const SettingsForm: FC<SettingsFormProps> = ({ tooltipClassName }) => {
 
   return (
     <Form aria-label={ariaLabel} onSubmit={() => null}>
-      <ThemeToggle labelClassName={styles.label} value={false} />
-      <PrismThemeToggle labelClassName={styles.label} value={false} />
-      <MotionToggle labelClassName={styles.label} value={false} />
+      <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>
   );
diff --git a/src/components/organisms/layout/header.stories.tsx b/src/components/organisms/layout/header.stories.tsx
index 98d6377..3ceb337 100644
--- a/src/components/organisms/layout/header.stories.tsx
+++ b/src/components/organisms/layout/header.stories.tsx
@@ -8,11 +8,23 @@ 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',
@@ -52,6 +64,16 @@ export default {
         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: {
diff --git a/src/components/organisms/layout/header.test.tsx b/src/components/organisms/layout/header.test.tsx
index a9896f8..414d96f 100644
--- a/src/components/organisms/layout/header.test.tsx
+++ b/src/components/organisms/layout/header.test.tsx
@@ -16,11 +16,13 @@ describe('Header', () => {
   it('renders the website title', () => {
     render(
       <Header
+        ackeeStorageKey="ackee-tracking"
+        isHome={true}
+        motionStorageKey="reduced-motion"
+        nav={nav}
+        photo={photo}
         searchPage="#"
         title={title}
-        photo={photo}
-        nav={nav}
-        isHome={true}
       />
     );
     expect(
@@ -29,7 +31,16 @@ describe('Header', () => {
   });
 
   it('renders the main nav', () => {
-    render(<Header searchPage="#" title={title} photo={photo} nav={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
index 18ebb31..f6212c3 100644
--- a/src/components/organisms/layout/header.tsx
+++ b/src/components/organisms/layout/header.tsx
@@ -6,7 +6,10 @@ import Toolbar, { type ToolbarProps } from '../toolbar/toolbar';
 import styles from './header.module.scss';
 
 export type HeaderProps = BrandingProps &
-  Pick<ToolbarProps, 'nav' | 'searchPage'> & {
+  Pick<
+    ToolbarProps,
+    'ackeeStorageKey' | 'motionStorageKey' | 'nav' | 'searchPage'
+  > & {
     /**
      * Set additional classnames to the header element.
      */
@@ -18,12 +21,25 @@ export type HeaderProps = BrandingProps &
  *
  * Render the website header.
  */
-const Header: FC<HeaderProps> = ({ className, nav, searchPage, ...props }) => {
+const Header: FC<HeaderProps> = ({
+  ackeeStorageKey,
+  className,
+  motionStorageKey,
+  nav,
+  searchPage,
+  ...props
+}) => {
   return (
     <header className={`${styles.wrapper} ${className}`}>
       <div className={styles.body}>
         <Branding {...props} />
-        <Toolbar nav={nav} searchPage={searchPage} className={styles.toolbar} />
+        <Toolbar
+          ackeeStorageKey={ackeeStorageKey}
+          className={styles.toolbar}
+          motionStorageKey={motionStorageKey}
+          nav={nav}
+          searchPage={searchPage}
+        />
       </div>
     </header>
   );
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
index e92bf1b..c731048 100644
--- a/src/components/organisms/modals/search-modal.tsx
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -10,6 +10,7 @@ const DynamicSearchForm = dynamic(
   () => import('@components/organisms/forms/search-form'),
   {
     loading: () => <Spinner />,
+    ssr: false,
   }
 );
 
diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx
index acbf7d1..d6ed989 100644
--- a/src/components/organisms/modals/settings-modal.test.tsx
+++ b/src/components/organisms/modals/settings-modal.test.tsx
@@ -3,7 +3,12 @@ import SettingsModal from './settings-modal';
 
 describe('SettingsModal', () => {
   it('renders a fake heading', () => {
-    render(<SettingsModal />);
+    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
index e724076..5d14836 100644
--- a/src/components/organisms/modals/settings-modal.tsx
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -10,11 +10,15 @@ const DynamicSettingsForm = dynamic(
   () => import('@components/organisms/forms/settings-form'),
   {
     loading: () => <Spinner />,
+    ssr: false,
   }
 );
 
 export type SettingsModalProps = Pick<ModalProps, 'className'> &
-  Pick<SettingsFormProps, 'tooltipClassName'>;
+  Pick<
+    SettingsFormProps,
+    'ackeeStorageKey' | 'motionStorageKey' | 'tooltipClassName'
+  >;
 
 /**
  * SettingsModal component
diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx
index aab4b9e..9d04932 100644
--- a/src/components/organisms/toolbar/settings.stories.tsx
+++ b/src/components/organisms/toolbar/settings.stories.tsx
@@ -8,7 +8,21 @@ import Settings from './settings';
 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',
@@ -32,6 +46,16 @@ export default {
         required: true,
       },
     },
+    motionStorageKey: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set Reduced motion settings local storage key.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
     setIsActive: {
       control: {
         type: null,
diff --git a/src/components/organisms/toolbar/settings.test.tsx b/src/components/organisms/toolbar/settings.test.tsx
index 96a32c9..7ccb234 100644
--- a/src/components/organisms/toolbar/settings.test.tsx
+++ b/src/components/organisms/toolbar/settings.test.tsx
@@ -3,14 +3,28 @@ import Settings from './settings';
 
 describe('Settings', () => {
   it('renders a button to open settings modal', () => {
-    render(<Settings isActive={false} setIsActive={() => null} />);
+    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 isActive={true} setIsActive={() => null} />);
+    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
index 43d3190..9985ba0 100644
--- a/src/components/organisms/toolbar/settings.tsx
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -1,7 +1,7 @@
 import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label from '@components/atoms/forms/label';
 import Cog from '@components/atoms/icons/cog';
-import { FC, forwardRef, ForwardRefRenderFunction } from 'react';
+import { forwardRef, ForwardRefRenderFunction } from 'react';
 import { useIntl } from 'react-intl';
 import SettingsModal, {
   type SettingsModalProps,
@@ -9,7 +9,10 @@ import SettingsModal, {
 import settingsStyles from './settings.module.scss';
 import sharedStyles from './toolbar-items.module.scss';
 
-export type SettingsProps = {
+export type SettingsProps = Pick<
+  SettingsModalProps,
+  'ackeeStorageKey' | 'motionStorageKey' | 'tooltipClassName'
+> & {
   /**
    * Set additional classnames to the modal wrapper.
    */
@@ -22,14 +25,17 @@ export type SettingsProps = {
    * A callback function to handle button state.
    */
   setIsActive: CheckboxProps['setValue'];
-  /**
-   * Set additional classnames to the tooltip wrapper.
-   */
-  tooltipClassName?: SettingsModalProps['tooltipClassName'];
 };
 
 const Settings: ForwardRefRenderFunction<HTMLDivElement, SettingsProps> = (
-  { className = '', isActive, setIsActive, tooltipClassName = '' },
+  {
+    ackeeStorageKey,
+    className = '',
+    isActive,
+    motionStorageKey,
+    setIsActive,
+    tooltipClassName = '',
+  },
   ref
 ) => {
   const intl = useIntl();
@@ -62,7 +68,9 @@ const Settings: ForwardRefRenderFunction<HTMLDivElement, SettingsProps> = (
         <Cog />
       </Label>
       <SettingsModal
+        ackeeStorageKey={ackeeStorageKey}
         className={`${sharedStyles.modal} ${settingsStyles.modal} ${className}`}
+        motionStorageKey={motionStorageKey}
         tooltipClassName={tooltipClassName}
       />
     </div>
diff --git a/src/components/organisms/toolbar/toolbar-items.module.scss b/src/components/organisms/toolbar/toolbar-items.module.scss
index c970b71..20abe01 100644
--- a/src/components/organisms/toolbar/toolbar-items.module.scss
+++ b/src/components/organisms/toolbar/toolbar-items.module.scss
@@ -22,7 +22,7 @@
 .modal {
   position: absolute;
   top: var(--toolbar-size, calc(var(--btn-size) + var(--spacing-2xs)));
-  transition: all 0.8s ease-in-out 0s;
+  transition: all 0.8s ease-in-out 0s, background 0s;
 
   @include mix.media("screen") {
     @include mix.dimensions(null, "sm") {
diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx
index 477cb55..1f07e79 100644
--- a/src/components/organisms/toolbar/toolbar.stories.tsx
+++ b/src/components/organisms/toolbar/toolbar.stories.tsx
@@ -8,9 +8,21 @@ 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',
@@ -24,6 +36,16 @@ export default {
         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: {
diff --git a/src/components/organisms/toolbar/toolbar.test.tsx b/src/components/organisms/toolbar/toolbar.test.tsx
index 05e84ff..72965e8 100644
--- a/src/components/organisms/toolbar/toolbar.test.tsx
+++ b/src/components/organisms/toolbar/toolbar.test.tsx
@@ -10,7 +10,14 @@ const nav = [
 
 describe('Toolbar', () => {
   it('renders a navigation menu', () => {
-    render(<Toolbar nav={nav} searchPage="#" />);
+    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
index e4188fe..e196c09 100644
--- a/src/components/organisms/toolbar/toolbar.tsx
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -2,26 +2,33 @@ import useClickOutside from '@utils/hooks/use-click-outside';
 import { FC, useRef, useState } from 'react';
 import MainNav, { type MainNavProps } from '../toolbar/main-nav';
 import Search, { type SearchProps } from '../toolbar/search';
-import Settings from '../toolbar/settings';
+import Settings, { SettingsProps } from '../toolbar/settings';
 import styles from './toolbar.module.scss';
 
-export type ToolbarProps = Pick<SearchProps, 'searchPage'> & {
-  /**
-   * Set additional classnames to the toolbar wrapper.
-   */
-  className?: string;
-  /**
-   * The main nav items.
-   */
-  nav: MainNavProps['items'];
-};
+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> = ({ className = '', nav, searchPage }) => {
+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);
@@ -53,11 +60,13 @@ const Toolbar: FC<ToolbarProps> = ({ className = '', nav, searchPage }) => {
         ref={searchRef}
       />
       <Settings
+        ackeeStorageKey={ackeeStorageKey}
+        className={`${styles.modal} ${styles['modal--settings']}`}
         isActive={isSettingsOpened}
+        motionStorageKey={motionStorageKey}
+        ref={settingsRef}
         setIsActive={setIsSettingsOpened}
-        className={`${styles.modal} ${styles['modal--settings']}`}
         tooltipClassName={styles.tooltip}
-        ref={settingsRef}
       />
     </div>
   );
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 559eaed..8f0d4e7 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -193,14 +193,16 @@ const Layout: FC<LayoutProps> = ({
         {skipToContent}
       </ButtonLink>
       <Header
-        title={name}
+        ackeeStorageKey="ackee-tracking"
         baseline={baseline}
-        photo={picture}
+        className={styles.header}
+        isHome={isHome}
+        motionStorageKey="reduced-motion"
         nav={mainNav}
+        photo={picture}
         searchPage="/recherche"
-        isHome={isHome}
+        title={name}
         withLink={true}
-        className={styles.header}
       />
       <Main id="main" className={styles.main}>
         <article
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index d171944..54b8d6e 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -20,6 +20,7 @@ import CommentsList, {
 import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
+import useCodeBlocksTheme from '@utils/hooks/use-code-blocks-theme';
 import useIsMounted from '@utils/hooks/use-is-mounted';
 import Script from 'next/script';
 import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
@@ -183,6 +184,8 @@ const PageLayout: FC<PageLayoutProps> = ({
     return Object.values(meta).every((value) => value === null);
   };
 
+  useCodeBlocksTheme(bodyRef);
+
   return (
     <>
       <Script
diff --git a/src/services/local-storage/index.ts b/src/services/local-storage/index.ts
index 8ed9ebf..65235a7 100644
--- a/src/services/local-storage/index.ts
+++ b/src/services/local-storage/index.ts
@@ -1,15 +1,15 @@
 export const LocalStorage = {
-  get(key: string): string | null | undefined {
+  get<T>(key: string): T | undefined {
     try {
       const serialItem = localStorage.getItem(key);
       if (!serialItem) return undefined;
-      return JSON.parse(serialItem);
+      return JSON.parse(serialItem) as T;
     } catch (e) {
       console.log(e);
       return undefined;
     }
   },
-  set(key: string, value: string) {
+  set<T>(key: string, value: T) {
     try {
       const serialItem = JSON.stringify(value);
       localStorage.setItem(key, serialItem);
diff --git a/src/utils/hooks/use-attributes.tsx b/src/utils/hooks/use-attributes.tsx
new file mode 100644
index 0000000..97a7b43
--- /dev/null
+++ b/src/utils/hooks/use-attributes.tsx
@@ -0,0 +1,33 @@
+import { useEffect } from 'react';
+
+export type useAttributesProps = {
+  /**
+   * An HTML element.
+   */
+  element?: HTMLElement;
+  /**
+   * The attribute name.
+   */
+  attribute: string;
+  /**
+   * The attribute value.
+   */
+  value: string;
+};
+
+/**
+ * Set HTML attributes to the given element or to the HTML document.
+ *
+ * @param props - An object with element, attribute name and value.
+ */
+const useAttributes = ({ element, attribute, value }: useAttributesProps) => {
+  useEffect(() => {
+    if (element) {
+      element.dataset[attribute] = value;
+    } else {
+      document.documentElement.dataset[attribute] = value;
+    }
+  }, [attribute, element, value]);
+};
+
+export default useAttributes;
diff --git a/src/utils/hooks/use-code-blocks-theme.tsx b/src/utils/hooks/use-code-blocks-theme.tsx
new file mode 100644
index 0000000..beb7b29
--- /dev/null
+++ b/src/utils/hooks/use-code-blocks-theme.tsx
@@ -0,0 +1,22 @@
+import { usePrismTheme } from '@utils/providers/prism-theme';
+import { useRouter } from 'next/router';
+import { RefObject, useEffect, useState } from 'react';
+import useIsMounted from './use-is-mounted';
+
+const useCodeBlocksTheme = (el: RefObject<HTMLDivElement>) => {
+  const [preElements, setPreElements] = useState<NodeListOf<HTMLPreElement>>();
+  const isMounted = useIsMounted(el);
+  const { setCodeBlocks } = usePrismTheme();
+  const { asPath } = useRouter();
+
+  useEffect(() => {
+    const result = document.querySelectorAll<HTMLPreElement>('pre');
+    setPreElements(result);
+  }, [asPath]);
+
+  useEffect(() => {
+    isMounted && preElements && setCodeBlocks(preElements);
+  }, [isMounted, preElements, setCodeBlocks]);
+};
+
+export default useCodeBlocksTheme;
diff --git a/src/utils/hooks/use-local-storage.tsx b/src/utils/hooks/use-local-storage.tsx
new file mode 100644
index 0000000..da0292b
--- /dev/null
+++ b/src/utils/hooks/use-local-storage.tsx
@@ -0,0 +1,35 @@
+import { LocalStorage } from '@services/local-storage';
+import { Dispatch, SetStateAction, useEffect, useState } from 'react';
+
+export type UseLocalStorageReturn<T> = {
+  value: T;
+  setValue: Dispatch<SetStateAction<T>>;
+};
+
+/**
+ * Use the local storage.
+ *
+ * @param {string} key - The storage local key.
+ * @param {T} [fallbackValue] - A fallback value if local storage is empty.
+ * @returns {UseLocalStorageReturn<T>} An object with value and setValue.
+ */
+const useLocalStorage = <T extends unknown>(
+  key: string,
+  fallbackValue: T
+): UseLocalStorageReturn<T> => {
+  const getInitialValue = () => {
+    if (typeof window === 'undefined') return fallbackValue;
+    const storedValue = LocalStorage.get<T>(key);
+    return storedValue || fallbackValue;
+  };
+
+  const [value, setValue] = useState<T>(getInitialValue);
+
+  useEffect(() => {
+    LocalStorage.set(key, value);
+  }, [key, value]);
+
+  return { value, setValue };
+};
+
+export default useLocalStorage;
diff --git a/src/utils/hooks/use-query-selector-all.tsx b/src/utils/hooks/use-query-selector-all.tsx
new file mode 100644
index 0000000..dbeec90
--- /dev/null
+++ b/src/utils/hooks/use-query-selector-all.tsx
@@ -0,0 +1,16 @@
+import { useEffect, useState } from 'react';
+
+const useQuerySelectorAll = <T extends keyof HTMLElementTagNameMap>(
+  query: string
+) => {
+  const [elements, setElements] =
+    useState<NodeListOf<HTMLElementTagNameMap[T]>>();
+
+  useEffect(() => {
+    setElements(document.querySelectorAll(query));
+  }, [query]);
+
+  return elements;
+};
+
+export default useQuerySelectorAll;
diff --git a/src/utils/hooks/use-update-ackee-options.tsx b/src/utils/hooks/use-update-ackee-options.tsx
new file mode 100644
index 0000000..7c1d98a
--- /dev/null
+++ b/src/utils/hooks/use-update-ackee-options.tsx
@@ -0,0 +1,19 @@
+import { useAckeeTracker } from '@utils/providers/ackee';
+import { useEffect } from 'react';
+
+export type AckeeOptions = 'full' | 'partial';
+
+/**
+ * Update Ackee settings with the given choice.
+ *
+ * @param {AckeeOptions} value - Either `full` or `partial`.
+ */
+const useUpdateAckeeOptions = (value: AckeeOptions) => {
+  const { setDetailed } = useAckeeTracker();
+
+  useEffect(() => {
+    setDetailed(value === 'full');
+  }, [value, setDetailed]);
+};
+
+export default useUpdateAckeeOptions;
diff --git a/src/utils/providers/prism-theme.tsx b/src/utils/providers/prism-theme.tsx
index 62bcf56..23a2a36 100644
--- a/src/utils/providers/prism-theme.tsx
+++ b/src/utils/providers/prism-theme.tsx
@@ -55,7 +55,7 @@ const isValidTheme = (theme: string): boolean => {
 
 const getTheme = (key: string): PrismTheme | undefined => {
   if (typeof window === 'undefined') return undefined;
-  const storageValue = LocalStorage.get(key);
+  const storageValue = LocalStorage.get<string>(key);
 
   return storageValue && isValidTheme(storageValue)
     ? (storageValue as PrismTheme)
-- 
cgit v1.2.3

