From 3a68d155afd4559e141bcb6c1ca3d833d3bd4667 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Thu, 21 Apr 2022 17:58:36 +0200
Subject: chore: add a Pagination component

---
 src/components/molecules/nav/pagination.tsx | 220 ++++++++++++++++++++++++++++
 1 file changed, 220 insertions(+)
 create mode 100644 src/components/molecules/nav/pagination.tsx

(limited to 'src/components/molecules/nav/pagination.tsx')

diff --git a/src/components/molecules/nav/pagination.tsx b/src/components/molecules/nav/pagination.tsx
new file mode 100644
index 0000000..38f6841
--- /dev/null
+++ b/src/components/molecules/nav/pagination.tsx
@@ -0,0 +1,220 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import { FC, Fragment, ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+import styles from './pagination.module.scss';
+
+export type PaginationProps = {
+  /**
+   * An accessible name for the pagination.
+   */
+  'aria-label'?: string;
+  /**
+   * The url part before page number. Default: /page/
+   */
+  baseUrl?: string;
+  /**
+   * Set additional classnames to the pagination wrapper.
+   */
+  className?: string;
+  /**
+   * The current page number.
+   */
+  current: number;
+  /**
+   * The number of items per page.
+   */
+  perPage: number;
+  /**
+   * The number of siblings on one side of the current page. Default: 1.
+   */
+  siblings?: number;
+  /**
+   * The total number of items.
+   */
+  total: number;
+};
+
+/**
+ * Pagination component
+ *
+ * Render a page-based navigation.
+ */
+const Pagination: FC<PaginationProps> = ({
+  baseUrl = '/page/',
+  className = '',
+  current,
+  perPage,
+  siblings = 1,
+  total,
+  ...props
+}) => {
+  const intl = useIntl();
+  const totalPages = Math.ceil(total / perPage);
+  const hasPreviousPage = current > 1;
+  const previousPageName = intl.formatMessage(
+    {
+      defaultMessage: '{icon} Previous page',
+      description: 'Pagination: previous page link',
+      id: 'aMFqPH',
+    },
+    { icon: '←' }
+  );
+  const previousPageUrl = `${baseUrl}${current - 1}`;
+  const hasNextPage = current < totalPages;
+  const nextPageName = intl.formatMessage(
+    {
+      defaultMessage: 'Next page {icon}',
+      description: 'Pagination: Next page link',
+      id: 'R4yaW6',
+    },
+    { icon: '→' }
+  );
+  const nextPageUrl = `${baseUrl}${current + 1}`;
+
+  /**
+   * Create an array with a range of values from start value to end value.
+   *
+   * @param {number} start - The first value.
+   * @param {number} end - The last value.
+   * @returns {number[]} An array from start value to end value.
+   */
+  const range = (start: number, end: number): number[] => {
+    const length = end - start + 1;
+
+    return Array.from({ length }, (_, index) => index + start);
+  };
+
+  /**
+   * Get the pagination range.
+   *
+   * @param currentPage - The current page number.
+   * @param maxPages - The total pages number.
+   * @returns {(number|string)[]} An array of page numbers with or without dots.
+   */
+  const getPaginationRange = (
+    currentPage: number,
+    maxPages: number
+  ): (number | string)[] => {
+    const dots = '\u2026';
+
+    /**
+     * Show left dots if current page less left siblings is greater than the
+     * first two pages.
+     */
+    const hasLeftDots = currentPage - siblings > 2;
+
+    /**
+     * Show right dots if current page plus right siblings is lower than the
+     * total of pages less the last page.
+     */
+    const hasRightDots = currentPage + siblings < maxPages - 1;
+
+    if (hasLeftDots && hasRightDots) {
+      const middleItems = range(currentPage - siblings, currentPage + siblings);
+      return [1, dots, ...middleItems, dots, maxPages];
+    }
+
+    if (hasLeftDots) {
+      const rightItems = range(currentPage - siblings, maxPages);
+      return [1, dots, ...rightItems];
+    }
+
+    if (hasRightDots) {
+      const leftItems = range(1, currentPage + siblings);
+      return [...leftItems, dots, maxPages];
+    }
+
+    return range(1, maxPages);
+  };
+
+  /**
+   * Get a link or a span wrapped in a list item.
+   *
+   * @param {string} id - The item id.
+   * @param {ReactNode} body - The link body.
+   * @param {string} [link] - An URL.
+   * @returns {JSX.Element} The list item.
+   */
+  const getItem = (id: string, body: ReactNode, link?: string): JSX.Element => {
+    const linkModifier = id.startsWith('page') ? 'link--number' : '';
+    const kind = id === 'previous' || id === 'next' ? 'tertiary' : 'secondary';
+
+    return (
+      <li className={styles.item}>
+        {link ? (
+          <ButtonLink
+            kind={kind}
+            target={link}
+            className={`${styles.link} ${styles[linkModifier]}`}
+          >
+            {body}
+          </ButtonLink>
+        ) : (
+          <span className={`${styles.link} ${styles['link--disabled']}`}>
+            {body}
+          </span>
+        )}
+      </li>
+    );
+  };
+
+  /**
+   * Get the list of pages.
+   *
+   * @param {number} currentPage - The current page number.
+   * @param {number} maxPages - The total of pages.
+   * @returns {JSX.Element[]} The list items.
+   */
+  const getPages = (currentPage: number, maxPages: number): JSX.Element[] => {
+    const pagesRange = getPaginationRange(currentPage, maxPages);
+
+    return pagesRange.map((page, index) => {
+      const id = typeof page === 'string' ? `dots-${index}` : `page-${page}`;
+      const currentPagePrefix = intl.formatMessage({
+        defaultMessage: 'You are here:',
+        description: 'Pagination: current page indication',
+        id: 'yE/Jdz',
+      });
+      const body =
+        typeof page === 'string'
+          ? '\u2026'
+          : intl.formatMessage(
+              {
+                defaultMessage: '<a11y>Page </a11y>{number}',
+                description: 'Pagination: page number',
+                id: 'TSXPzr',
+              },
+              {
+                number: page,
+                a11y: (chunks: ReactNode) => (
+                  <span className="screen-reader-text">
+                    {page === currentPage && currentPagePrefix}
+                    {chunks}
+                  </span>
+                ),
+              }
+            );
+      const url =
+        page === currentPage || typeof page === 'string'
+          ? undefined
+          : `${baseUrl}${page}`;
+
+      return <Fragment key={`item-${id}`}>{getItem(id, body, url)}</Fragment>;
+    });
+  };
+
+  return (
+    <nav className={`${styles.wrapper} ${className}`} {...props}>
+      <ul className={styles.list}>
+        {hasPreviousPage &&
+          getItem('previous', previousPageName, previousPageUrl)}
+        {hasNextPage && getItem('next', nextPageName, nextPageUrl)}
+      </ul>
+      <ul className={`${styles.list} ${styles['list--pages']}`}>
+        {getPages(current, totalPages)}
+      </ul>
+    </nav>
+  );
+};
+
+export default Pagination;
-- 
cgit v1.2.3


From f4c7ab4e306d2f04324853e67032d370abd65d0c Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 20 May 2022 15:37:08 +0200
Subject: chore: handle blog pagination when JS is disabled

---
 .../molecules/nav/pagination.module.scss           |   2 +-
 src/components/molecules/nav/pagination.tsx        |  10 +-
 src/components/organisms/layout/posts-list.tsx     |  95 +++++--
 src/pages/blog/index.tsx                           |   1 +
 src/pages/blog/page/[number].tsx                   | 311 +++++++++++++++++++++
 src/pages/recherche/index.tsx                      |   1 +
 src/pages/sujet/[slug].tsx                         |   1 +
 src/pages/thematique/[slug].tsx                    |   1 +
 src/services/graphql/api.ts                        |   9 +
 src/services/graphql/articles.query.ts             |  12 +
 src/services/graphql/articles.ts                   |  28 +-
 src/utils/hooks/use-breadcrumb.tsx                 |   3 +-
 src/utils/hooks/use-redirection.tsx                |  33 +++
 13 files changed, 472 insertions(+), 35 deletions(-)
 create mode 100644 src/pages/blog/page/[number].tsx
 create mode 100644 src/utils/hooks/use-redirection.tsx

(limited to 'src/components/molecules/nav/pagination.tsx')

diff --git a/src/components/molecules/nav/pagination.module.scss b/src/components/molecules/nav/pagination.module.scss
index a8cef47..56c5bfc 100644
--- a/src/components/molecules/nav/pagination.module.scss
+++ b/src/components/molecules/nav/pagination.module.scss
@@ -13,7 +13,7 @@
 
     &--pages {
       column-gap: var(--spacing-2xs);
-      margin-top: var(--spacing-sm);
+      margin-bottom: var(--spacing-sm);
     }
   }
 
diff --git a/src/components/molecules/nav/pagination.tsx b/src/components/molecules/nav/pagination.tsx
index 38f6841..934b50a 100644
--- a/src/components/molecules/nav/pagination.tsx
+++ b/src/components/molecules/nav/pagination.tsx
@@ -44,12 +44,12 @@ const Pagination: FC<PaginationProps> = ({
   className = '',
   current,
   perPage,
-  siblings = 1,
+  siblings = 2,
   total,
   ...props
 }) => {
   const intl = useIntl();
-  const totalPages = Math.ceil(total / perPage);
+  const totalPages = Math.round(total / perPage);
   const hasPreviousPage = current > 1;
   const previousPageName = intl.formatMessage(
     {
@@ -205,14 +205,14 @@ const Pagination: FC<PaginationProps> = ({
 
   return (
     <nav className={`${styles.wrapper} ${className}`} {...props}>
+      <ul className={`${styles.list} ${styles['list--pages']}`}>
+        {getPages(current, totalPages)}
+      </ul>
       <ul className={styles.list}>
         {hasPreviousPage &&
           getItem('previous', previousPageName, previousPageUrl)}
         {hasNextPage && getItem('next', nextPageName, nextPageUrl)}
       </ul>
-      <ul className={`${styles.list} ${styles['list--pages']}`}>
-        {getPages(current, totalPages)}
-      </ul>
     </nav>
   );
 };
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 9dfe254..50192dd 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -2,6 +2,11 @@ import Button from '@components/atoms/buttons/button';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import ProgressBar from '@components/atoms/loaders/progress-bar';
 import Spinner from '@components/atoms/loaders/spinner';
+import Pagination, {
+  PaginationProps,
+} from '@components/molecules/nav/pagination';
+import useIsMounted from '@utils/hooks/use-is-mounted';
+import useSettings from '@utils/hooks/use-settings';
 import { FC, Fragment, useRef } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './posts-list.module.scss';
@@ -18,7 +23,7 @@ export type YearCollection = {
   [key: string]: Post[];
 };
 
-export type PostsListProps = {
+export type PostsListProps = Pick<PaginationProps, 'baseUrl' | 'siblings'> & {
   /**
    * True to display the posts by year. Default: false.
    */
@@ -31,6 +36,10 @@ export type PostsListProps = {
    * Load more button handler.
    */
   loadMore?: () => void;
+  /**
+   * The current page number. Default: 1.
+   */
+  pageNumber?: number;
   /**
    * The posts data.
    */
@@ -74,16 +83,22 @@ const sortPostsByYear = (data: Post[]): YearCollection => {
  * Render a list of post summaries.
  */
 const PostsList: FC<PostsListProps> = ({
+  baseUrl,
   byYear = false,
   isLoading = false,
   loadMore,
+  pageNumber = 1,
   posts,
   showLoadMoreBtn = false,
+  siblings,
   titleLevel,
   total,
 }) => {
   const intl = useIntl();
+  const listRef = useRef<HTMLOListElement>(null);
   const lastPostRef = useRef<HTMLSpanElement>(null);
+  const isMounted = useIsMounted(listRef);
+  const { blog } = useSettings();
 
   /**
    * Retrieve the list of posts.
@@ -99,7 +114,7 @@ const PostsList: FC<PostsListProps> = ({
     const lastPostId = allPosts[allPosts.length - 1].id;
 
     return (
-      <ol className={styles.list}>
+      <ol className={styles.list} ref={listRef}>
         {allPosts.map(({ id, ...post }) => (
           <Fragment key={id}>
             <li className={styles.item}>
@@ -168,34 +183,60 @@ const PostsList: FC<PostsListProps> = ({
     loadMore && loadMore();
   };
 
-  return posts.length === 0 ? (
-    <p>
-      {intl.formatMessage({
-        defaultMessage: 'No results found.',
-        description: 'PostsList: no results',
-        id: 'vK7Sxv',
-      })}
-    </p>
-  ) : (
+  const getProgressBar = () => {
+    return (
+      <>
+        <ProgressBar
+          min={1}
+          max={total}
+          current={posts.length}
+          info={progressInfo}
+        />
+        {showLoadMoreBtn && (
+          <Button
+            kind="tertiary"
+            onClick={loadMorePosts}
+            disabled={isLoading}
+            className={styles.btn}
+          >
+            {loadMoreBody}
+          </Button>
+        )}
+      </>
+    );
+  };
+
+  const getPagination = () => {
+    return posts.length <= blog.postsPerPage ? (
+      <Pagination
+        baseUrl={baseUrl}
+        current={pageNumber}
+        perPage={blog.postsPerPage}
+        siblings={siblings}
+        total={total}
+      />
+    ) : (
+      <></>
+    );
+  };
+
+  if (posts.length === 0) {
+    return (
+      <p>
+        {intl.formatMessage({
+          defaultMessage: 'No results found.',
+          description: 'PostsList: no results',
+          id: 'vK7Sxv',
+        })}
+      </p>
+    );
+  }
+
+  return (
     <>
       {getPosts()}
       {isLoading && <Spinner />}
-      <ProgressBar
-        min={1}
-        max={total}
-        current={posts.length}
-        info={progressInfo}
-      />
-      {showLoadMoreBtn && (
-        <Button
-          kind="tertiary"
-          onClick={loadMorePosts}
-          disabled={isLoading}
-          className={styles.btn}
-        >
-          {loadMoreBody}
-        </Button>
-      )}
+      {isMounted ? getProgressBar() : getPagination()}
     </>
   );
 };
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 2676305..90f56be 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -250,6 +250,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
       >
         {data && (
           <PostsList
+            baseUrl="/blog/page/"
             byYear={true}
             isLoading={isLoadingMore || isLoadingInitialData}
             loadMore={loadMore}
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
new file mode 100644
index 0000000..e8c93f7
--- /dev/null
+++ b/src/pages/blog/page/[number].tsx
@@ -0,0 +1,311 @@
+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 {
+  getArticleFromRawData,
+  getArticles,
+  getArticlesEndCursor,
+  getTotalArticles,
+} from '@services/graphql/articles';
+import {
+  getThematicsPreview,
+  getTotalThematics,
+} from '@services/graphql/thematics';
+import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
+import {
+  type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
+} from '@ts/types/raw-data';
+import { settings } from '@utils/config';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import {
+  getLinksListItems,
+  getPageLinkFromRawData,
+} from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
+import useRedirection from '@utils/hooks/use-redirection';
+import useSettings from '@utils/hooks/use-settings';
+import { GetStaticPaths, GetStaticProps } 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, Graph, WebPage } from 'schema-dts';
+
+type BlogPageProps = {
+  articles: EdgesResponse<RawArticle>;
+  pageNumber: number;
+  thematicsList: RawThematicPreview[];
+  topicsList: RawTopicPreview[];
+  totalArticles: number;
+  translation: Messages;
+};
+
+/**
+ * Blog index page.
+ */
+const BlogPage: NextPageWithLayout<BlogPageProps> = ({
+  articles,
+  pageNumber,
+  thematicsList,
+  topicsList,
+  totalArticles,
+}) => {
+  useRedirection({
+    query: { param: 'number', value: '1' },
+    redirectTo: '/blog',
+  });
+
+  const intl = useIntl();
+  const title = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'BlogPage: page title',
+    id: '7TbbIk',
+  });
+  const pageNumberTitle = intl.formatMessage(
+    {
+      defaultMessage: 'Page {number}',
+      id: 'zbzlb1',
+      description: 'BlogPage: page number',
+    },
+    {
+      number: pageNumber,
+    }
+  );
+  const pageTitleWithPageNumber = `${title} - ${pageNumberTitle}`;
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title: pageNumberTitle,
+    url: `/blog/page/${pageNumber}`,
+  });
+
+  const { website } = useSettings();
+  const { asPath } = useRouter();
+  const pageTitle = `${pageTitleWithPageNumber} - ${website.name}`;
+  const pageDescription = intl.formatMessage(
+    {
+      defaultMessage:
+        "Discover {websiteName}'s writings. He talks about web development, Linux and open source mostly.",
+      description: 'BlogPage: SEO - Meta description',
+      id: '18h/t0',
+    },
+    { websiteName: website.name }
+  );
+  const pageUrl = `${website.url}${asPath}`;
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    name: pageTitle,
+    description: pageDescription,
+    inLanguage: website.locales.default,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${website.url}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const blogSchema: Blog = {
+    '@id': `${website.url}/#blog`,
+    '@type': 'Blog',
+    author: { '@id': `${website.url}/#branding` },
+    creator: { '@id': `${website.url}/#branding` },
+    editor: { '@id': `${website.url}/#branding` },
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, blogSchema],
+  };
+
+  /**
+   * 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))
+    );
+  };
+
+  const thematicsListTitle = intl.formatMessage({
+    defaultMessage: 'Thematics',
+    description: 'BlogPage: thematics list widget title',
+    id: 'HriY57',
+  });
+
+  const topicsListTitle = intl.formatMessage({
+    defaultMessage: 'Topics',
+    description: 'BlogPage: topics list widget title',
+    id: '2D9tB5',
+  });
+
+  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={pageTitleWithPageNumber} />
+        <meta property="og:description" content={pageDescription} />
+      </Head>
+      <Script
+        id="schema-blog"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        title={pageTitleWithPageNumber}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
+        headerMeta={{ total: totalArticles }}
+        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}
+          />,
+        ]}
+      >
+        <PostsList
+          baseUrl="/blog/page/"
+          byYear={true}
+          pageNumber={pageNumber}
+          posts={getPostsList([articles])}
+          total={totalArticles}
+        />
+      </PageLayout>
+    </>
+  );
+};
+
+BlogPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
+interface BlogPageParams extends ParsedUrlQuery {
+  number: string;
+}
+
+export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
+  locale,
+  params,
+}) => {
+  const pageNumber = Number(params!.number as BlogPageParams['number']);
+  const queriedPostsNumber = settings.postsPerPage * pageNumber;
+  const lastCursor = await getArticlesEndCursor({
+    first: queriedPostsNumber,
+  });
+  const articles = await getArticles({
+    first: settings.postsPerPage,
+    after: lastCursor,
+  });
+  const totalArticles = await getTotalArticles();
+  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: {
+      articles: JSON.parse(JSON.stringify(articles)),
+      pageNumber,
+      thematicsList: thematics.edges.map((edge) => edge.node),
+      topicsList: topics.edges.map((edge) => edge.node),
+      totalArticles,
+      translation,
+    },
+  };
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  const totalArticles = await getTotalArticles();
+  const totalPages = Math.ceil(totalArticles / settings.postsPerPage);
+  const pagesArray = Array.from(
+    { length: totalPages },
+    (_, index) => index + 1
+  );
+  const paths = pagesArray.map((number) => {
+    return { params: { number: `${number}` } };
+  });
+
+  return {
+    paths,
+    fallback: false,
+  };
+};
+
+export default BlogPage;
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 09091c8..8895015 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -263,6 +263,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
       >
         {data && data.length > 0 ? (
           <PostsList
+            baseUrl="/recherche/page/"
             byYear={true}
             isLoading={isLoadingMore || isLoadingInitialData}
             loadMore={loadMore}
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 6277293..271f4ec 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -205,6 +205,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
               )}
             </Heading>
             <PostsList
+              baseUrl="/sujet/page/"
               posts={getPosts(articles)}
               total={articles.length}
               titleLevel={3}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index fbe50fc..ce4eccf 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -186,6 +186,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
               )}
             </Heading>
             <PostsList
+              baseUrl="/thematique/page/"
               posts={getPosts(articles)}
               total={articles.length}
               titleLevel={3}
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts
index 9f68ddc..009aea4 100644
--- a/src/services/graphql/api.ts
+++ b/src/services/graphql/api.ts
@@ -2,6 +2,7 @@ import { settings } from '@utils/config';
 import {
   articleBySlugQuery,
   articlesCardQuery,
+  articlesEndCursor,
   articlesQuery,
   articlesSlugQuery,
   totalArticlesQuery,
@@ -28,6 +29,7 @@ export type Queries =
   | typeof articlesQuery
   | typeof articleBySlugQuery
   | typeof articlesCardQuery
+  | typeof articlesEndCursor
   | typeof articlesSlugQuery
   | typeof commentsQuery
   | typeof thematicBySlugQuery
@@ -100,9 +102,15 @@ export type NodesResponse<T> = {
   nodes: T[];
 };
 
+export type EndCursor = Pick<
+  EdgesResponse<Pick<PageInfo, 'endCursor'>>,
+  'pageInfo'
+>;
+
 export type ResponseMap<T> = {
   [articleBySlugQuery]: ArticleResponse<T>;
   [articlesCardQuery]: ArticlesResponse<NodesResponse<T>>;
+  [articlesEndCursor]: ArticlesResponse<EndCursor>;
   [articlesQuery]: ArticlesResponse<EdgesResponse<T>>;
   [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>;
   [commentsQuery]: CommentsResponse<NodesResponse<T>>;
@@ -213,6 +221,7 @@ export type SendMailVars = {
 export type VariablesMap = {
   [articleBySlugQuery]: BySlugVar;
   [articlesCardQuery]: EdgesVars;
+  [articlesEndCursor]: EdgesVars;
   [articlesQuery]: EdgesVars;
   [articlesSlugQuery]: EdgesVars;
   [commentsQuery]: ByContentIdVar;
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index e02ca8e..7bd0901 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -184,3 +184,15 @@ export const totalArticlesQuery = `query PostsTotal($search: String = "") {
     }
   }
 }`;
+
+/**
+ * Query the end cursor based on the queried posts number.
+ */
+export const articlesEndCursor = `query EndCursorAfter($first: Int) {
+  posts(first: $first) {
+    pageInfo {
+      hasNextPage
+      endCursor
+    }
+  }
+}`;
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index 41052c4..f130872 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -7,10 +7,18 @@ import {
 import { getAuthorFromRawData } from '@utils/helpers/author';
 import { getImageFromRawData } from '@utils/helpers/images';
 import { getPageLinkFromRawData } from '@utils/helpers/pages';
-import { EdgesResponse, EdgesVars, fetchAPI, getAPIUrl, PageInfo } from './api';
+import {
+  EdgesResponse,
+  EdgesVars,
+  EndCursor,
+  fetchAPI,
+  getAPIUrl,
+  PageInfo,
+} from './api';
 import {
   articleBySlugQuery,
   articlesCardQuery,
+  articlesEndCursor,
   articlesQuery,
   articlesSlugQuery,
   totalArticlesQuery,
@@ -173,3 +181,21 @@ export const getAllArticlesSlugs = async (): Promise<string[]> => {
 
   return response.posts.edges.map((edge) => edge.node.slug);
 };
+
+/**
+ * Retrieve the last cursor.
+ *
+ * @param {EdgesVars} props - An object of GraphQL variables.
+ * @returns {Promise<string>} - The end cursor.
+ */
+export const getArticlesEndCursor = async (
+  props: EdgesVars
+): Promise<string> => {
+  const response = await fetchAPI<EndCursor, typeof articlesEndCursor>({
+    api: getAPIUrl(),
+    query: articlesEndCursor,
+    variables: { ...props },
+  });
+
+  return response.posts.pageInfo.endCursor;
+};
diff --git a/src/utils/hooks/use-breadcrumb.tsx b/src/utils/hooks/use-breadcrumb.tsx
index 087d400..130ebf1 100644
--- a/src/utils/hooks/use-breadcrumb.tsx
+++ b/src/utils/hooks/use-breadcrumb.tsx
@@ -40,6 +40,7 @@ const useBreadcrumb = ({
   const { website } = useSettings();
   const isArticle = url.startsWith('/article/');
   const isHome = url === '/';
+  const isPageNumber = url.includes('/page/');
   const isProject = url.startsWith('/projets/');
   const isSearch = url.startsWith('/recherche');
   const isThematic = url.startsWith('/thematique/');
@@ -62,7 +63,7 @@ const useBreadcrumb = ({
 
   if (isHome) return { items, schema };
 
-  if (isArticle || isSearch || isThematic || isTopic) {
+  if (isArticle || isPageNumber || isSearch || isThematic || isTopic) {
     const blogLabel = intl.formatMessage({
       defaultMessage: 'Blog',
       description: 'Breadcrumb: blog label',
diff --git a/src/utils/hooks/use-redirection.tsx b/src/utils/hooks/use-redirection.tsx
new file mode 100644
index 0000000..9eb26c2
--- /dev/null
+++ b/src/utils/hooks/use-redirection.tsx
@@ -0,0 +1,33 @@
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+export type RouterQuery = {
+  param: string;
+  value: string;
+};
+
+export type UseRedirectionProps = {
+  /**
+   * The router query.
+   */
+  query: RouterQuery;
+  /**
+   * The redirection url.
+   */
+  redirectTo: string;
+};
+
+/**
+ * Redirect to another url when router query match the given parameters.
+ *
+ * @param {UseRedirectionProps} props - The redirection parameters.
+ */
+const useRedirection = ({ query, redirectTo }: UseRedirectionProps) => {
+  const router = useRouter();
+
+  useEffect(() => {
+    if (router.query[query.param] === query.value) router.push(redirectTo);
+  }, [query, redirectTo, router]);
+};
+
+export default useRedirection;
-- 
cgit v1.2.3

