diff options
20 files changed, 652 insertions, 377 deletions
diff --git a/src/i18n/en.json b/src/i18n/en.json index f760860..b1768a8 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -147,6 +147,10 @@ "defaultMessage": "{date} at {time}", "description": "Time: readable date and time" }, + "8xVO3Y": { + "defaultMessage": "Blog - Page {number}", + "description": "BlogPage: page title with number" + }, "9MTBCG": { "defaultMessage": "{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}", "description": "PostPreviewMeta: thematics label" @@ -343,6 +347,10 @@ "defaultMessage": "Share by Email", "description": "SharingWidget: Email sharing link" }, + "OsclKU": { + "defaultMessage": "Topics are loading...", + "description": "BlogPage: loading topics message" + }, "PBdVsm": { "defaultMessage": "{starsCount, plural, =0 {No stars} one {# star} other {# stars}}", "description": "ProjectOverview: stars count" @@ -459,6 +467,10 @@ "defaultMessage": "Name:", "description": "CommentForm: name label" }, + "ZMES/E": { + "defaultMessage": "You can't load more articles without Javascript, please use the pagination instead.", + "description": "BlogPage: pagination no script message" + }, "ZNBhDP": { "defaultMessage": "Search results for {query}", "description": "SearchPage: SEO - Page title" @@ -507,6 +519,10 @@ "defaultMessage": "{website} picture", "description": "SiteBranding: photo alternative text" }, + "dG3sT3": { + "defaultMessage": "Blog: development, open source - Page {number} - {websiteName}", + "description": "BlogPage: SEO - Page title" + }, "eys2uX": { "defaultMessage": "Table of Contents", "description": "PageLayout: table of contents title" @@ -703,14 +719,14 @@ "defaultMessage": "{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}", "description": "PostPreviewMeta: rounded minutes count" }, + "y37FuH": { + "defaultMessage": "Thematics are loading...", + "description": "BlogPage: loading thematics message" + }, "yN5P+m": { "defaultMessage": "Message:", "description": "ContactForm: message label" }, - "zbzlb1": { - "defaultMessage": "Page {number}", - "description": "BlogPage: page number" - }, "zhjPcZ": { "defaultMessage": "Settings form", "description": "SiteNavbar: an accessible name for the settings form in navbar" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 9a098fc..50c9ca7 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -147,6 +147,10 @@ "defaultMessage": "{date} à {time}", "description": "Time: readable date and time" }, + "8xVO3Y": { + "defaultMessage": "Blog - Page {number}", + "description": "BlogPage: page title with number" + }, "9MTBCG": { "defaultMessage": "{thematicsCount, plural, =0 {Thématiques :} one {Thématique :} other {Thématiques :}}", "description": "PostPreviewMeta: thematics label" @@ -343,6 +347,10 @@ "defaultMessage": "Partager par email", "description": "SharingWidget: Email sharing link" }, + "OsclKU": { + "defaultMessage": "Les sujets sont en cours de chargement…", + "description": "BlogPage: loading topics message" + }, "PBdVsm": { "defaultMessage": "{starsCount, plural, =0 {0 étoile} one {# étoile} other {# étoiles}}", "description": "ProjectOverview: stars count" @@ -459,6 +467,10 @@ "defaultMessage": "Nom :", "description": "CommentForm: name label" }, + "ZMES/E": { + "defaultMessage": "Vous ne pouvez pas charger plus d’articles sans Javascript, veuillez utiliser la pagination.", + "description": "BlogPage: pagination no script message" + }, "ZNBhDP": { "defaultMessage": "Résultats de la recherche pour {query}", "description": "SearchPage: SEO - Page title" @@ -507,6 +519,10 @@ "defaultMessage": "Photo d’{website}", "description": "SiteBranding: photo alternative text" }, + "dG3sT3": { + "defaultMessage": "Blog: développement, libre et open-source - Page {number} - {websiteName}", + "description": "BlogPage: SEO - Page title" + }, "eys2uX": { "defaultMessage": "Table des matières", "description": "PageLayout: table of contents title" @@ -703,14 +719,14 @@ "defaultMessage": "{minutesCount, plural, =0 {Moins d’une minute} one {# minute} other {# minutes}}", "description": "PostPreviewMeta: rounded minutes count" }, + "y37FuH": { + "defaultMessage": "Les thématiques sont en cours de chargement…", + "description": "BlogPage: loading thematics message" + }, "yN5P+m": { "defaultMessage": "Message :", "description": "ContactForm: message label" }, - "zbzlb1": { - "defaultMessage": "Page {number}", - "description": "BlogPage: page number" - }, "zhjPcZ": { "defaultMessage": "Formulaire des réglages", "description": "SiteNavbar: an accessible name for the settings form in navbar" diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 12bc03e..df25cd2 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,9 +1,8 @@ /* eslint-disable max-statements */ import type { GetStaticProps } from 'next'; import Head from 'next/head'; -import { useRouter } from 'next/router'; import Script from 'next/script'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { getLayout, @@ -18,11 +17,11 @@ import { PageHeader, PageBody, PageSidebar, + Spinner, } from '../../components'; import { convertWPThematicPreviewToPageLink, convertWPTopicPreviewToPageLink, - fetchPostsCount, fetchPostsList, fetchThematicsCount, fetchThematicsList, @@ -47,71 +46,30 @@ import { getWebPageSchema, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; -import { useBreadcrumb, useIsMounted, usePostsList } from '../../utils/hooks'; +import { + useArticlesList, + useBreadcrumb, + useThematicsList, + useTopicsList, +} from '../../utils/hooks'; + +const renderPaginationLink: RenderPaginationLink = (pageNum) => + `${ROUTES.BLOG}/page/${pageNum}`; type BlogPageProps = { - posts: GraphQLConnection<WPPostPreview>; - thematicsList: WPThematicPreview[]; - topicsList: WPTopicPreview[]; - totalArticles: number; + data: { + posts: GraphQLConnection<WPPostPreview>; + thematics: GraphQLConnection<WPThematicPreview>; + topics: GraphQLConnection<WPTopicPreview>; + }; translation: Messages; }; /** * Blog index page. */ -const BlogPage: NextPageWithLayout<BlogPageProps> = ({ - posts, - thematicsList, - topicsList, - totalArticles, -}) => { +const BlogPage: NextPageWithLayout<BlogPageProps> = ({ data }) => { const intl = useIntl(); - const title = intl.formatMessage({ - defaultMessage: 'Blog', - description: 'BlogPage: page title', - id: '7TbbIk', - }); - const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ - title, - url: ROUTES.BLOG, - }); - const postsListRef = useRef<HTMLDivElement>(null); - const isMounted = useIsMounted(postsListRef); - const { asPath } = useRouter(); - const page = { - title: intl.formatMessage( - { - defaultMessage: 'Blog: development, open source - {websiteName}', - description: 'BlogPage: SEO - Page title', - id: '+Y+tLK', - }, - { websiteName: CONFIG.name } - ), - url: `${CONFIG.url}${asPath}`, - }; - 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: CONFIG.name } - ); - const webpageSchema = getWebPageSchema({ - description: pageDescription, - locale: CONFIG.locales.defaultLocale, - slug: asPath, - title, - }); - const blogSchema = getBlogSchema({ - isSinglePage: false, - locale: CONFIG.locales.defaultLocale, - slug: asPath, - }); - const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); - const { articles, error, @@ -121,27 +79,101 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ isRefreshing, hasNextPage, loadMore, - } = usePostsList({ - fallback: [posts], - fetcher: fetchPostsList, + } = useArticlesList({ + fallback: [data.posts], perPage: CONFIG.postsPerPage, }); + const { isLoading: areThematicsLoading, thematics } = useThematicsList({ + fallback: data.thematics, + input: { first: data.thematics.pageInfo.total }, + }); + const { isLoading: areTopicsLoading, topics } = useTopicsList({ + fallback: data.topics, + input: { first: data.topics.pageInfo.total }, + }); - const thematicsListTitle = intl.formatMessage({ - defaultMessage: 'Thematics', - description: 'BlogPage: thematics list widget title', - id: 'HriY57', + const messages = { + loading: { + thematicsList: intl.formatMessage({ + defaultMessage: 'Thematics are loading...', + description: 'BlogPage: loading thematics message', + id: 'y37FuH', + }), + topicsList: intl.formatMessage({ + defaultMessage: 'Topics are loading...', + description: 'BlogPage: loading topics message', + id: 'OsclKU', + }), + }, + pageTitle: intl.formatMessage({ + defaultMessage: 'Blog', + description: 'BlogPage: page title', + id: '7TbbIk', + }), + pagination: { + noJS: intl.formatMessage({ + defaultMessage: + "You can't load more articles without Javascript, please use the pagination instead.", + description: 'BlogPage: pagination no script message', + id: 'ZMES/E', + }), + title: intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'BlogPage: pagination accessible name', + id: 'AXe1Iz', + }), + }, + seo: { + metaDesc: 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: CONFIG.name } + ), + title: intl.formatMessage( + { + defaultMessage: 'Blog: development, open source - {websiteName}', + description: 'BlogPage: SEO - Page title', + id: '+Y+tLK', + }, + { websiteName: CONFIG.name } + ), + }, + widgets: { + thematicsListTitle: intl.formatMessage({ + defaultMessage: 'Thematics', + description: 'BlogPage: thematics list widget title', + id: 'HriY57', + }), + topicsListTitle: intl.formatMessage({ + defaultMessage: 'Topics', + description: 'BlogPage: topics list widget title', + id: '2D9tB5', + }), + }, + }; + + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title: messages.pageTitle, + url: ROUTES.BLOG, }); - const topicsListTitle = intl.formatMessage({ - defaultMessage: 'Topics', - description: 'BlogPage: topics list widget title', - id: '2D9tB5', + const webpageSchema = getWebPageSchema({ + description: messages.seo.metaDesc, + locale: CONFIG.locales.defaultLocale, + slug: ROUTES.BLOG, + title: messages.pageTitle, }); - const renderPaginationLink: RenderPaginationLink = useCallback( - (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`, - [] - ); + const blogSchema = getBlogSchema({ + isSinglePage: false, + locale: CONFIG.locales.defaultLocale, + slug: ROUTES.BLOG, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); + const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( ({ kind, pageNumber: number, isCurrentPage }) => { switch (kind) { @@ -187,27 +219,19 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ [intl] ); - const paginationAriaLabel = intl.formatMessage({ - defaultMessage: 'Pagination', - description: 'BlogPage: pagination accessible name', - id: 'AXe1Iz', - }); - - const blogArticles = articles?.flatMap((p) => - p.edges.map((edge) => edge.node) - ); + const pageUrl = `${CONFIG.url}${ROUTES.BLOG}`; return ( <Page breadcrumbs={breadcrumbItems} isBodyLastChild> <Head> - <title>{page.title}</title> + <title>{messages.seo.title}</title> {/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */} - <meta name="description" content={pageDescription} /> - <meta property="og:url" content={page.url} /> + <meta name="description" content={messages.seo.metaDesc} /> + <meta property="og:url" content={pageUrl} /> {/*eslint-disable-next-line react/jsx-no-literals -- Content allowed */} <meta property="og:type" content="website" /> - <meta property="og:title" content={title} /> - <meta property="og:description" content={pageDescription} /> + <meta property="og:title" content={messages.pageTitle} /> + <meta property="og:description" content={messages.seo.metaDesc} /> </Head> <Script // eslint-disable-next-line react/jsx-no-literals -- Id allowed @@ -222,30 +246,24 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ id="schema-breadcrumb" type="application/ld+json" /> - <PageHeader heading={title} meta={{ total: totalArticles }} /> - <PageBody className={styles.body}> - {blogArticles ? ( + <PageHeader + heading={messages.pageTitle} + meta={{ total: data.posts.pageInfo.total }} + /> + <PageBody> + {articles ? ( <PostsList - className={styles.list} + className={styles['posts-list']} firstNewResult={firstNewResultIndex} isLoading={isLoading || isLoadingMore || isRefreshing} - onLoadMore={hasNextPage && isMounted ? loadMore : undefined} - posts={getPostsWithUrl(blogArticles)} - ref={postsListRef} + onLoadMore={hasNextPage ? loadMore : undefined} + posts={getPostsWithUrl( + articles.flatMap((page) => page.edges.map((edge) => edge.node)) + )} sortByYear - total={isMounted ? totalArticles : undefined} + total={data.posts.pageInfo.total} /> ) : null} - {isMounted ? null : ( - <Pagination - aria-label={paginationAriaLabel} - current={1} - isCentered - renderItemAriaLabel={renderPaginationLabel} - renderLink={renderPaginationLink} - total={totalArticles} - /> - )} {error ? ( <Notice // eslint-disable-next-line react/jsx-no-literals -- Kind allowed @@ -258,28 +276,53 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ })} </Notice> ) : null} + <noscript> + <Notice + // eslint-disable-next-line react/jsx-no-literals + kind="info" + > + {messages.pagination.noJS} + </Notice> + <Pagination + aria-label={messages.pagination.title} + className={styles.pagination} + current={1} + isCentered + renderItemAriaLabel={renderPaginationLabel} + renderLink={renderPaginationLink} + total={data.posts.pageInfo.total} + /> + </noscript> </PageBody> <PageSidebar> - <LinksWidget - heading={ - <Heading isFake level={3}> - {thematicsListTitle} - </Heading> - } - items={getLinksItemData( - thematicsList.map(convertWPThematicPreviewToPageLink) - )} - /> - <LinksWidget - heading={ - <Heading isFake level={3}> - {topicsListTitle} - </Heading> - } - items={getLinksItemData( - topicsList.map(convertWPTopicPreviewToPageLink) - )} - /> + {areThematicsLoading ? ( + <Spinner>{messages.loading.thematicsList}</Spinner> + ) : ( + <LinksWidget + heading={ + <Heading level={2}>{messages.widgets.thematicsListTitle}</Heading> + } + items={getLinksItemData( + thematics.edges.map((edge) => + convertWPThematicPreviewToPageLink(edge.node) + ) + )} + /> + )} + {areTopicsLoading ? ( + <Spinner>{messages.loading.topicsList}</Spinner> + ) : ( + <LinksWidget + heading={ + <Heading level={2}>{messages.widgets.topicsListTitle}</Heading> + } + items={getLinksItemData( + topics.edges.map((edge) => + convertWPTopicPreviewToPageLink(edge.node) + ) + )} + /> + )} </PageSidebar> </Page> ); @@ -291,7 +334,6 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ locale, }) => { const posts = await fetchPostsList({ first: CONFIG.postsPerPage }); - const totalArticles = await fetchPostsCount(); const totalThematics = await fetchThematicsCount(); const thematics = await fetchThematicsList({ first: totalThematics }); const totalTopics = await fetchTopicsCount(); @@ -300,10 +342,11 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ return { props: { - posts: JSON.parse(JSON.stringify(posts)), - thematicsList: thematics.edges.map((edge) => edge.node), - topicsList: topics.edges.map((edge) => edge.node), - totalArticles, + data: { + posts: JSON.parse(JSON.stringify(posts)), + thematics, + topics, + }, translation, }, }; diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx index 35d4bad..ec465c2 100644 --- a/src/pages/blog/page/[number].tsx +++ b/src/pages/blog/page/[number].tsx @@ -18,6 +18,9 @@ import { PageHeader, PageBody, PageSidebar, + Spinner, + Notice, + LoadingPage, } from '../../../components'; import { convertWPThematicPreviewToPageLink, @@ -30,9 +33,12 @@ import { fetchTopicsCount, fetchTopicsList, } from '../../../services/graphql'; +import styles from '../../../styles/pages/blog.module.scss'; import type { GraphQLConnection, + Maybe, NextPageWithLayout, + Nullable, WPPostPreview, WPThematicPreview, WPTopicPreview, @@ -48,17 +54,24 @@ import { } from '../../../utils/helpers'; import { loadTranslation, type Messages } from '../../../utils/helpers/server'; import { + useArticlesList, useBreadcrumb, - usePostsList, useRedirection, + useThematicsList, + useTopicsList, } from '../../../utils/hooks'; +const renderPaginationLink: RenderPaginationLink = (pageNum) => + `${ROUTES.BLOG}/page/${pageNum}`; + type BlogPageProps = { + data: { + posts: GraphQLConnection<WPPostPreview>; + thematics: GraphQLConnection<WPThematicPreview>; + topics: GraphQLConnection<WPTopicPreview>; + }; + lastCursor: Maybe<Nullable<string>>; pageNumber: number; - posts: GraphQLConnection<WPPostPreview>; - thematicsList: WPThematicPreview[]; - topicsList: WPTopicPreview[]; - totalArticles: number; translation: Messages; }; @@ -66,86 +79,129 @@ type BlogPageProps = { * Blog index page. */ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ + data, + lastCursor, pageNumber, - posts, - thematicsList, - topicsList, - totalArticles, }) => { useRedirection({ - query: { param: 'number', value: '1' }, - redirectTo: ROUTES.BLOG, + isReplacing: true, + to: ROUTES.BLOG, + whenPathMatches: (path) => path === `${ROUTES.BLOG}/page/1`, }); - const { articles } = usePostsList({ - fallback: [posts], - fetcher: fetchPostsList, + const intl = useIntl(); + const { isFallback } = useRouter(); + const { + articles, + error, + firstNewResultIndex, + isLoading, + isLoadingMore, + isRefreshing, + hasNextPage, + loadMore, + } = useArticlesList({ + after: lastCursor, + fallback: [data.posts], perPage: CONFIG.postsPerPage, }); - const intl = useIntl(); - const title = intl.formatMessage({ - defaultMessage: 'Blog', - description: 'BlogPage: page title', - id: '7TbbIk', + const { isLoading: areThematicsLoading, thematics } = useThematicsList({ + fallback: data.thematics, + input: { first: data.thematics.pageInfo.total }, + }); + const { isLoading: areTopicsLoading, topics } = useTopicsList({ + fallback: data.topics, + input: { first: data.topics.pageInfo.total }, }); - const pageNumberTitle = intl.formatMessage( - { - defaultMessage: 'Page {number}', - id: 'zbzlb1', - description: 'BlogPage: page number', + + const messages = { + loading: { + thematicsList: intl.formatMessage({ + defaultMessage: 'Thematics are loading...', + description: 'BlogPage: loading thematics message', + id: 'y37FuH', + }), + topicsList: intl.formatMessage({ + defaultMessage: 'Topics are loading...', + description: 'BlogPage: loading topics message', + id: 'OsclKU', + }), }, - { - number: pageNumber, - } - ); - const pageTitleWithPageNumber = `${title} - ${pageNumberTitle}`; + pageTitle: intl.formatMessage( + { + defaultMessage: 'Blog - Page {number}', + description: 'BlogPage: page title with number', + id: '8xVO3Y', + }, + { + number: pageNumber, + } + ), + pagination: { + noJS: intl.formatMessage({ + defaultMessage: + "You can't load more articles without Javascript, please use the pagination instead.", + description: 'BlogPage: pagination no script message', + id: 'ZMES/E', + }), + title: intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'BlogPage: pagination accessible name', + id: 'AXe1Iz', + }), + }, + seo: { + metaDesc: 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: CONFIG.name } + ), + title: intl.formatMessage( + { + defaultMessage: + 'Blog: development, open source - Page {number} - {websiteName}', + description: 'BlogPage: SEO - Page title', + id: 'dG3sT3', + }, + { number: pageNumber, websiteName: CONFIG.name } + ), + }, + widgets: { + thematicsListTitle: intl.formatMessage({ + defaultMessage: 'Thematics', + description: 'BlogPage: thematics list widget title', + id: 'HriY57', + }), + topicsListTitle: intl.formatMessage({ + defaultMessage: 'Topics', + description: 'BlogPage: topics list widget title', + id: '2D9tB5', + }), + }, + }; + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ - title: pageNumberTitle, + title: messages.pageTitle, url: `${ROUTES.BLOG}/page/${pageNumber}`, }); - const { asPath } = useRouter(); - const page = { - title: `${pageTitleWithPageNumber} - ${CONFIG.name}`, - url: `${CONFIG.url}${asPath}`, - }; - 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: CONFIG.name } - ); const webpageSchema = getWebPageSchema({ - description: pageDescription, + description: messages.seo.metaDesc, locale: CONFIG.locales.defaultLocale, - slug: asPath, - title, + slug: ROUTES.BLOG, + title: messages.pageTitle, }); const blogSchema = getBlogSchema({ isSinglePage: false, locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug: ROUTES.BLOG, }); const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); - 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', - }); - const renderPaginationLink: RenderPaginationLink = useCallback( - (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`, - [] - ); const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( ({ kind, pageNumber: number, isCurrentPage }) => { switch (kind) { @@ -191,27 +247,21 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ [intl] ); - const paginationAriaLabel = intl.formatMessage({ - defaultMessage: 'Pagination', - description: 'BlogPage: pagination accessible name', - id: 'AXe1Iz', - }); + if (isFallback) return <LoadingPage />; - const blogPageArticles = articles?.flatMap((p) => - p.edges.map((edge) => edge.node) - ); + const pageUrl = `${CONFIG.url}${ROUTES.BLOG}`; return ( <Page breadcrumbs={breadcrumbItems} isBodyLastChild> <Head> - <title>{page.title}</title> + <title>{messages.seo.title}</title> {/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */} - <meta name="description" content={pageDescription} /> - <meta property="og:url" content={page.url} /> + <meta name="description" content={messages.seo.metaDesc} /> + <meta property="og:url" content={pageUrl} /> {/*eslint-disable-next-line react/jsx-no-literals -- Content allowed */} <meta property="og:type" content="website" /> - <meta property="og:title" content={pageTitleWithPageNumber} /> - <meta property="og:description" content={pageDescription} /> + <meta property="og:title" content={messages.pageTitle} /> + <meta property="og:description" content={messages.seo.metaDesc} /> </Head> <Script // eslint-disable-next-line react/jsx-no-literals -- Id allowed @@ -227,41 +277,82 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ type="application/ld+json" /> <PageHeader - heading={pageTitleWithPageNumber} - meta={{ total: totalArticles }} + heading={messages.pageTitle} + meta={{ total: data.posts.pageInfo.total }} /> <PageBody> - <PostsList posts={getPostsWithUrl(blogPageArticles ?? [])} sortByYear /> - <Pagination - aria-label={paginationAriaLabel} - current={pageNumber} - isCentered - renderItemAriaLabel={renderPaginationLabel} - renderLink={renderPaginationLink} - total={totalArticles} - /> + {articles ? ( + <PostsList + className={styles['posts-list']} + firstNewResult={firstNewResultIndex} + isLoading={isLoading || isLoadingMore || isRefreshing} + onLoadMore={hasNextPage ? loadMore : undefined} + posts={getPostsWithUrl( + articles.flatMap((page) => page.edges.map((edge) => edge.node)) + )} + sortByYear + total={data.posts.pageInfo.total} + /> + ) : null} + {error ? ( + <Notice + // eslint-disable-next-line react/jsx-no-literals -- Kind allowed + kind="error" + > + {intl.formatMessage({ + defaultMessage: 'Failed to load.', + description: 'BlogPage: failed to load text', + id: 'C/XGkH', + })} + </Notice> + ) : null} + <noscript> + <Notice + // eslint-disable-next-line react/jsx-no-literals + kind="info" + > + {messages.pagination.noJS} + </Notice> + <Pagination + aria-label={messages.pagination.title} + className={styles.pagination} + current={pageNumber} + isCentered + renderItemAriaLabel={renderPaginationLabel} + renderLink={renderPaginationLink} + total={data.posts.pageInfo.total} + /> + </noscript> </PageBody> <PageSidebar> - <LinksWidget - heading={ - <Heading isFake level={3}> - {thematicsListTitle} - </Heading> - } - items={getLinksItemData( - thematicsList.map(convertWPThematicPreviewToPageLink) - )} - /> - <LinksWidget - heading={ - <Heading isFake level={3}> - {topicsListTitle} - </Heading> - } - items={getLinksItemData( - topicsList.map(convertWPTopicPreviewToPageLink) - )} - /> + {areThematicsLoading ? ( + <Spinner>{messages.loading.thematicsList}</Spinner> + ) : ( + <LinksWidget + heading={ + <Heading level={2}>{messages.widgets.thematicsListTitle}</Heading> + } + items={getLinksItemData( + thematics.edges.map((edge) => + convertWPThematicPreviewToPageLink(edge.node) + ) + )} + /> + )} + {areTopicsLoading ? ( + <Spinner>{messages.loading.topicsList}</Spinner> + ) : ( + <LinksWidget + heading={ + <Heading level={2}>{messages.widgets.topicsListTitle}</Heading> + } + items={getLinksItemData( + topics.edges.map((edge) => + convertWPTopicPreviewToPageLink(edge.node) + ) + )} + /> + )} </PageSidebar> </Page> ); @@ -278,14 +369,23 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ params, }) => { const pageNumber = Number((params as BlogPageParams).number); - const lastCursor = await fetchLastPostCursor( - CONFIG.postsPerPage * pageNumber - ); + + if (pageNumber === 1) + return { + redirect: { + destination: ROUTES.BLOG, + permanent: true, + }, + }; + + const lastCursor = + pageNumber > 1 + ? await fetchLastPostCursor(CONFIG.postsPerPage * (pageNumber - 1)) + : null; const posts = await fetchPostsList({ first: CONFIG.postsPerPage, after: lastCursor, }); - const totalArticles = await fetchPostsCount(); const totalThematics = await fetchThematicsCount(); const thematics = await fetchThematicsList({ first: totalThematics }); const totalTopics = await fetchTopicsCount(); @@ -294,11 +394,13 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ return { props: { - posts: JSON.parse(JSON.stringify(posts)), + data: { + posts: JSON.parse(JSON.stringify(posts)), + thematics, + topics, + }, + lastCursor, pageNumber, - thematicsList: thematics.edges.map((edge) => edge.node), - topicsList: topics.edges.map((edge) => edge.node), - totalArticles, translation, }, }; @@ -317,7 +419,7 @@ export const getStaticPaths: GetStaticPaths = async () => { return { paths, - fallback: false, + fallback: true, }; }; diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index bb3aa53..2bcb1c0 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -23,7 +23,6 @@ import { convertWPThematicPreviewToPageLink, convertWPTopicPreviewToPageLink, fetchPostsCount, - fetchPostsList, fetchThematicsCount, fetchThematicsList, fetchTopicsCount, @@ -45,7 +44,11 @@ import { getWebPageSchema, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; -import { useBreadcrumb, useDataFromAPI, usePostsList } from '../../utils/hooks'; +import { + useArticlesList, + useBreadcrumb, + useDataFromAPI, +} from '../../utils/hooks'; type SearchPageProps = { thematicsList: WPThematicPreview[]; @@ -125,9 +128,8 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ isRefreshing, hasNextPage, loadMore, - } = usePostsList({ + } = useArticlesList({ fallback: [], - fetcher: fetchPostsList, perPage: CONFIG.postsPerPage, searchQuery: query.s as string, }); diff --git a/src/styles/pages/Page.module.scss b/src/styles/pages/Page.module.scss deleted file mode 100644 index 5c2848e..0000000 --- a/src/styles/pages/Page.module.scss +++ /dev/null @@ -1,44 +0,0 @@ -@use "../abstracts/functions" as fun; -@use "../abstracts/placeholders"; - -.article { - composes: grid from "../layout/_grid.scss"; - align-items: start; - - > header { - grid-column: 1 / -1; - } - - > footer, - .body { - grid-column: 2; - } - - &--no-comments { - margin-bottom: var(--spacing-xl); - } -} - -.body noscript { - display: block; - width: 100%; - text-align: center; -} - -li.item { - margin: 0 0 var(--spacing-md) 0; - border-bottom: fun.convert-px(1) solid var(--color-border); -} - -.comments { - grid-column: 1 / -1; - composes: grid from "../layout/_grid.scss"; - margin: var(--spacing-md) 0 0; - padding: var(--spacing-md) 0 var(--spacing-lg); - background: var(--color-bg-secondary); - border-top: fun.convert-px(3) solid var(--color-border-light); - - > * { - grid-column: 2; - } -} diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss index e8d0034..553e9f9 100644 --- a/src/styles/pages/blog.module.scss +++ b/src/styles/pages/blog.module.scss @@ -7,6 +7,22 @@ @use "partials/article-media"; @use "partials/article-wp-blocks"; +.posts-list { + @include mix.media("screen") { + @include mix.dimensions("md") { + --col1: #{fun.convert-px(100)}; + --gap: var(--spacing-lg); + + margin-top: var(--spacing-md); + margin-left: calc((var(--col1) + var(--gap)) * -1); + } + } +} + +.pagination { + margin-top: var(--spacing-md); +} + .sharing-widget { @include mix.media("screen") { @include mix.dimensions("md") { diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index da4ed9e..1e0bfe3 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,5 +1,6 @@ export * from './use-ackee'; export * from './use-article'; +export * from './use-articles-list'; export * from './use-boolean'; export * from './use-breadcrumb'; export * from './use-comments'; @@ -7,13 +8,11 @@ export * from './use-data-from-api'; export * from './use-form'; export * from './use-github-api'; export * from './use-headings-tree'; -export * from './use-is-mounted'; export * from './use-local-storage'; export * from './use-match-media'; export * from './use-on-click-outside'; export * from './use-on-route-change'; export * from './use-pagination'; -export * from './use-posts-list'; export * from './use-prism'; export * from './use-prism-theme'; export * from './use-redirection'; diff --git a/src/utils/hooks/use-articles-list/index.ts b/src/utils/hooks/use-articles-list/index.ts new file mode 100644 index 0000000..5f42aeb --- /dev/null +++ b/src/utils/hooks/use-articles-list/index.ts @@ -0,0 +1 @@ +export * from './use-articles-list'; diff --git a/src/utils/hooks/use-posts-list/use-posts-list.test.tsx b/src/utils/hooks/use-articles-list/use-articles-list.test.tsx index f23ddde..6191ed6 100644 --- a/src/utils/hooks/use-posts-list/use-posts-list.test.tsx +++ b/src/utils/hooks/use-articles-list/use-articles-list.test.tsx @@ -6,11 +6,13 @@ import { it, jest, } from '@jest/globals'; -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { SWRConfig } from 'swr'; -import { fetchPostsList } from '../../../services/graphql'; -import { usePostsList } from './use-posts-list'; +import { wpPostsFixture } from '../../../../tests/fixtures'; +import { getConnection } from '../../../../tests/utils/graphql'; +import { convertPostPreviewToArticlePreview } from '../../../services/graphql'; +import { useArticlesList } from './use-articles-list'; const wrapper = ({ children }: { children?: ReactNode }) => { const map = new Map(); @@ -38,7 +40,7 @@ const wrapper = ({ children }: { children?: ReactNode }) => { ); }; -describe('usePostsList', () => { +describe('useArticlesList', () => { beforeEach(() => { /* Not sure why it is needed, but without it Jest was complaining with `You * are trying to import a file after the Jest environment has been torn @@ -55,10 +57,9 @@ describe('usePostsList', () => { it('can return the first new result index when loading more posts', async () => { const perPage = 5; - const { result } = renderHook( - () => usePostsList({ fetcher: fetchPostsList, perPage }), - { wrapper } - ); + const { result } = renderHook(() => useArticlesList({ perPage }), { + wrapper, + }); expect.assertions(2); @@ -71,4 +72,38 @@ describe('usePostsList', () => { // Assuming there is more than one page. expect(result.current.firstNewResultIndex).toBe(perPage + 1); }); + + it('converts a WordPress post connection to an article connection', async () => { + const perPage = 1; + const { result } = renderHook(() => useArticlesList({ perPage }), { + wrapper, + }); + const connection = getConnection({ + after: null, + data: wpPostsFixture, + first: perPage, + }); + + expect.hasAssertions(); + + await waitFor(() => { + expect(result.current.articles).toBeDefined(); + }); + + expect(result.current.articles).toStrictEqual([ + { + edges: connection.edges.map((edge) => { + return { + cursor: edge.cursor, + node: convertPostPreviewToArticlePreview(edge.node), + }; + }), + pageInfo: { + endCursor: connection.pageInfo.endCursor, + hasNextPage: connection.pageInfo.hasNextPage, + total: connection.pageInfo.total, + }, + }, + ]); + }); }); diff --git a/src/utils/hooks/use-posts-list/use-posts-list.ts b/src/utils/hooks/use-articles-list/use-articles-list.ts index bb77f31..8a52702 100644 --- a/src/utils/hooks/use-posts-list/use-posts-list.ts +++ b/src/utils/hooks/use-articles-list/use-articles-list.ts @@ -1,4 +1,8 @@ import { useCallback, useState } from 'react'; +import { + convertPostPreviewToArticlePreview, + fetchPostsList, +} from '../../../services/graphql'; import type { ArticlePreview, GraphQLConnection, @@ -11,9 +15,8 @@ import { usePagination, type UsePaginationReturn, } from '../use-pagination'; -import { convertPostPreviewToArticlePreview } from 'src/services/graphql'; -export type usePostsListReturn = Omit< +export type useArticlesListReturn = Omit< UsePaginationReturn<WPPostPreview>, 'data' > & { @@ -27,9 +30,9 @@ export type usePostsListReturn = Omit< firstNewResultIndex: Maybe<number>; }; -export const usePostsList = ( - config: UsePaginationConfig<WPPostPreview> -): usePostsListReturn => { +export const useArticlesList = ( + config: Omit<UsePaginationConfig<WPPostPreview>, 'fetcher'> +): useArticlesListReturn => { const { data, error, @@ -42,7 +45,7 @@ export const usePostsList = ( isValidating, loadMore, size, - } = usePagination(config); + } = usePagination({ ...config, fetcher: fetchPostsList }); const [firstNewResultIndex, setFirstNewResultIndex] = useState<Maybe<number>>(undefined); @@ -53,15 +56,15 @@ export const usePostsList = ( }, [config.perPage, loadMore, size]); const articles: Maybe<GraphQLConnection<ArticlePreview>[]> = data?.map( - (page): GraphQLConnection<ArticlePreview> => { + ({ edges, pageInfo }): GraphQLConnection<ArticlePreview> => { return { - edges: page.edges.map((edge): GraphQLEdge<ArticlePreview> => { + edges: edges.map((edge): GraphQLEdge<ArticlePreview> => { return { cursor: edge.cursor, node: convertPostPreviewToArticlePreview(edge.node), }; }), - pageInfo: page.pageInfo, + pageInfo, }; } ); diff --git a/src/utils/hooks/use-is-mounted.tsx b/src/utils/hooks/use-is-mounted.tsx deleted file mode 100644 index 4d85d45..0000000 --- a/src/utils/hooks/use-is-mounted.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { RefObject, useEffect, useState } from 'react'; - -/** - * Check if an HTML element is mounted. - * - * @param {RefObject<HTMLElement>} ref - A React reference to an HTML element. - * @returns {boolean} True if the HTML element is mounted. - */ -export const useIsMounted = (ref: RefObject<HTMLElement>): boolean => { - const [isMounted, setIsMounted] = useState<boolean>(false); - - useEffect(() => { - if (ref.current) setIsMounted(true); - }, [ref]); - - return isMounted; -}; diff --git a/src/utils/hooks/use-pagination/use-pagination.ts b/src/utils/hooks/use-pagination/use-pagination.ts index 2a40aa4..29d5ba2 100644 --- a/src/utils/hooks/use-pagination/use-pagination.ts +++ b/src/utils/hooks/use-pagination/use-pagination.ts @@ -11,7 +11,7 @@ export type UsePaginationFetcherInput = GraphQLEdgesInput & { search?: string; }; -export type UsePaginationConfig<T> = { +export type UsePaginationConfig<T> = Pick<GraphQLEdgesInput, 'after'> & { /** * The initial data. */ @@ -86,6 +86,7 @@ export type UsePaginationReturn<T> = { * @returns {UsePaginationReturn} An object with pagination data and helpers. */ export const usePagination = <T>({ + after, fallback, fetcher, perPage, @@ -97,12 +98,11 @@ export const usePagination = <T>({ return { first: perPage, - after: - pageIndex === 0 ? undefined : previousPageData?.pageInfo.endCursor, + after: pageIndex === 0 ? after : previousPageData?.pageInfo.endCursor, search: searchQuery, }; }, - [perPage, searchQuery] + [after, perPage, searchQuery] ); const { data, error, isLoading, isValidating, setSize, size } = diff --git a/src/utils/hooks/use-posts-list/index.ts b/src/utils/hooks/use-posts-list/index.ts deleted file mode 100644 index 664c142..0000000 --- a/src/utils/hooks/use-posts-list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './use-posts-list'; diff --git a/src/utils/hooks/use-redirection.tsx b/src/utils/hooks/use-redirection.tsx deleted file mode 100644 index 5a677e2..0000000 --- a/src/utils/hooks/use-redirection.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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. - */ -export const useRedirection = ({ query, redirectTo }: UseRedirectionProps) => { - const router = useRouter(); - - useEffect(() => { - if (router.query[query.param] === query.value) router.push(redirectTo); - }, [query, redirectTo, router]); -}; diff --git a/src/utils/hooks/use-redirection/index.ts b/src/utils/hooks/use-redirection/index.ts new file mode 100644 index 0000000..c81c82c --- /dev/null +++ b/src/utils/hooks/use-redirection/index.ts @@ -0,0 +1 @@ +export * from './use-redirection'; diff --git a/src/utils/hooks/use-redirection/use-redirection.test.ts b/src/utils/hooks/use-redirection/use-redirection.test.ts new file mode 100644 index 0000000..c14ac4c --- /dev/null +++ b/src/utils/hooks/use-redirection/use-redirection.test.ts @@ -0,0 +1,80 @@ +import { describe, it } from '@jest/globals'; +import { renderHook } from '@testing-library/react'; +import nextRouterMock from 'next-router-mock'; +import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; +import { useRedirection } from './use-redirection'; + +describe('useRedirection', () => { + it('redirects to another page', async () => { + const initialPath = '/initial-path'; + const redirectPath = '/redirect-path'; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(2); + + await nextRouterMock.push('/initial-path'); + + expect(nextRouterMock.asPath).toBe(initialPath); + + renderHook(() => useRedirection({ to: redirectPath }), { + wrapper: MemoryRouterProvider, + }); + + expect(nextRouterMock.asPath).toBe(redirectPath); + }); + + it('can replace the url in the history', async () => { + const initialPath = '/initial-path'; + const redirectPath = '/redirect-path'; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(2); + + await nextRouterMock.push('/initial-path'); + + expect(nextRouterMock.asPath).toBe(initialPath); + + renderHook(() => useRedirection({ isReplacing: true, to: redirectPath }), { + wrapper: MemoryRouterProvider, + }); + + expect(nextRouterMock.asPath).toBe(redirectPath); + + /* Ideally we should check if when we use `back()` the current path is + * still the redirectPath but it is not yet implemented in the mock. */ + }); + + it('can conditionally redirect to another page', async () => { + const paths = { + initial: '/initial-path', + matching: '/matching-path', + redirect: '/redirect-path', + }; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(3); + + await nextRouterMock.push('/initial-path'); + + expect(nextRouterMock.asPath).toBe(paths.initial); + + const { rerender } = renderHook( + () => + useRedirection({ + to: paths.redirect, + whenPathMatches: (path) => path === paths.matching, + }), + { + wrapper: MemoryRouterProvider, + } + ); + + expect(nextRouterMock.asPath).toBe(paths.initial); + + await nextRouterMock.push(paths.matching); + + rerender(); + + expect(nextRouterMock.asPath).toBe(paths.redirect); + }); +}); diff --git a/src/utils/hooks/use-redirection/use-redirection.ts b/src/utils/hooks/use-redirection/use-redirection.ts new file mode 100644 index 0000000..1592a33 --- /dev/null +++ b/src/utils/hooks/use-redirection/use-redirection.ts @@ -0,0 +1,41 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +export type UseRedirectionConfig = { + /** + * Should the url be replaced in the history? + * + * @default false + */ + isReplacing?: boolean; + /** + * The destination. + */ + to: string; + /** + * Redirect only when the current path matches the condition. + * + * @param {string} path - The current slug. + * @returns {boolean} True if the path matches. + */ + whenPathMatches?: (path: string) => boolean; +}; + +export const useRedirection = ({ + isReplacing = false, + to, + whenPathMatches, +}: UseRedirectionConfig) => { + const router = useRouter(); + + useEffect(() => { + const shouldRedirect = whenPathMatches + ? whenPathMatches(router.asPath) + : true; + + if (shouldRedirect) { + if (isReplacing) router.replace(to, undefined, { shallow: true }); + else router.push(to); + } + }, [isReplacing, router, to, whenPathMatches]); +}; diff --git a/tests/cypress/e2e/pages/blog.cy.ts b/tests/cypress/e2e/pages/blog.cy.ts index 3a422d2..0350e39 100644 --- a/tests/cypress/e2e/pages/blog.cy.ts +++ b/tests/cypress/e2e/pages/blog.cy.ts @@ -11,6 +11,14 @@ describe('Blog Page', () => { cy.visit(ROUTES.BLOG); }); + it('successfully loads', () => { + cy.findByRole('heading', { level: 1 }).should('exist'); + }); + + it('contains a breadcrumbs', () => { + cy.findByRole('navigation', { name: 'Fil d’Ariane' }).should('exist'); + }); + it('loads the correct number of pages', () => { cy.findByText( /(?<first>\d+) articles chargés sur un total de (?<total>\d+)/i @@ -49,4 +57,9 @@ describe('Blog Page', () => { ); }); }); + + it('contains a thematics list widget and a topics list widget', () => { + cy.findByRole('heading', { level: 2, name: 'Thématiques' }).should('exist'); + cy.findByRole('heading', { level: 2, name: 'Sujets' }).should('exist'); + }); }); diff --git a/tests/fixtures/wp-posts.fixture.ts b/tests/fixtures/wp-posts.fixture.ts index a1b1e4a..7adc928 100644 --- a/tests/fixtures/wp-posts.fixture.ts +++ b/tests/fixtures/wp-posts.fixture.ts @@ -1,6 +1,6 @@ import type { WPPost } from '../../src/types'; -export const wpPostsFixture: WPPost[] = [ +export const wpPostsFixture = [ { acfPosts: null, author: { @@ -174,4 +174,4 @@ export const wpPostsFixture: WPPost[] = [ slug: '/post-4', title: 'Post 4', }, -]; +] satisfies WPPost[]; |
