diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/molecules/nav/pagination.module.scss | 2 | ||||
| -rw-r--r-- | src/components/molecules/nav/pagination.tsx | 10 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.tsx | 95 | ||||
| -rw-r--r-- | src/pages/blog/index.tsx | 1 | ||||
| -rw-r--r-- | src/pages/blog/page/[number].tsx | 311 | ||||
| -rw-r--r-- | src/pages/recherche/index.tsx | 1 | ||||
| -rw-r--r-- | src/pages/sujet/[slug].tsx | 1 | ||||
| -rw-r--r-- | src/pages/thematique/[slug].tsx | 1 | ||||
| -rw-r--r-- | src/services/graphql/api.ts | 9 | ||||
| -rw-r--r-- | src/services/graphql/articles.query.ts | 12 | ||||
| -rw-r--r-- | src/services/graphql/articles.ts | 28 | ||||
| -rw-r--r-- | src/utils/hooks/use-breadcrumb.tsx | 3 | ||||
| -rw-r--r-- | src/utils/hooks/use-redirection.tsx | 33 | 
13 files changed, 472 insertions, 35 deletions
| 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.     */ @@ -32,6 +37,10 @@ export type PostsListProps = {     */    loadMore?: () => void;    /** +   * The current page number. Default: 1. +   */ +  pageNumber?: number; +  /**     * The posts data.     */    posts: Post[]; @@ -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; | 
