diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Pagination/Pagination.module.scss | 92 | ||||
| -rw-r--r-- | src/components/Pagination/Pagination.tsx | 131 | ||||
| -rw-r--r-- | src/i18n/en.json | 24 | ||||
| -rw-r--r-- | src/i18n/fr.json | 24 | ||||
| -rw-r--r-- | src/pages/blog/index.tsx | 59 | ||||
| -rw-r--r-- | src/pages/blog/page/[id].tsx | 195 | ||||
| -rw-r--r-- | src/services/graphql/queries.ts | 29 | ||||
| -rw-r--r-- | src/ts/types/app.ts | 9 | ||||
| -rw-r--r-- | src/ts/types/blog.ts | 10 | ||||
| -rw-r--r-- | src/utils/helpers/format.ts | 17 |
10 files changed, 550 insertions, 40 deletions
diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 0000000..4d74d1b --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,92 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; +@use "@styles/abstracts/placeholders"; + +.list { + @extend %flex-list; + justify-content: center; + + row-gap: var(--spacing-sm); +} + +.link { + display: block; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--color-bg); + border: fun.convert-px(2) solid var(--color-primary); + box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 + var(--color-primary-darker); + font-weight: 600; + text-decoration: none; + + @include mix.pointer("fine") { + padding: var(--spacing-2xs) var(--spacing-xs); + } + + &--current { + padding: calc(var(--spacing-xs) / 1.5) var(--spacing-sm); + border-color: var(--color-primary-darker); + box-shadow: none; + color: var(--color-primary-darker); + transform: translateY(#{fun.convert-px(10)}); + + @include mix.pointer("fine") { + padding: calc(var(--spacing-2xs) / 1.5) var(--spacing-xs); + transform: translateY(#{fun.convert-px(7)}); + } + } + + &:not(.link--current) { + &:hover, + &:focus { + border-color: var(--color-primary-light); + box-shadow: fun.convert-px(2) fun.convert-px(2) 0 0 + var(--color-primary-darker), + 0 fun.convert-px(2) fun.convert-px(2) fun.convert-px(1) + var(--color-shadow-dark), + 0 fun.convert-px(7) fun.convert-px(7) fun.convert-px(2) + var(--color-shadow-light); + color: var(--color-primary-light); + transform: translateY(#{fun.convert-px(-5)}); + } + + &:active { + padding: calc(var(--spacing-xs) / 1.5) var(--spacing-sm); + border-color: var(--color-primary-dark); + box-shadow: none; + color: var(--color-primary-dark); + transform: translateY(#{fun.convert-px(10)}); + + @include mix.pointer("fine") { + padding: calc(var(--spacing-2xs) / 1.5) var(--spacing-xs); + transform: translateY(#{fun.convert-px(7)}); + } + } + } +} + +.item { + position: relative; + + &:first-child { + .link { + border-top-left-radius: fun.convert-px(4); + border-bottom-left-radius: fun.convert-px(4); + } + } + + &:last-child { + .link { + border-top-right-radius: fun.convert-px(4); + border-bottom-right-radius: fun.convert-px(4); + } + } + + &:not(:first-child) { + margin-left: fun.convert-px(-1); + } + + &:not(:last-child) { + margin-right: fun.convert-px(-1); + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000..2c24a8c --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,131 @@ +import { settings } from '@utils/config'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; +import styles from './Pagination.module.scss'; + +const Pagination = ({ baseUrl, total }: { baseUrl: string; total: number }) => { + const intl = useIntl(); + const { asPath } = useRouter(); + const totalPages = Math.floor(total / settings.postsPerPage); + const currentPage = asPath.includes('/page/') + ? Number(asPath.split(`${baseUrl}/page/`)[1]) + : 1; + const hasPreviousPage = currentPage !== 1; + const hasNextPage = currentPage !== totalPages; + + const getPreviousPageItem = () => { + return ( + <li className={styles.item}> + <Link href={`${baseUrl}/page/${currentPage - 1}`}> + <a className={styles.link}> + {intl.formatMessage( + { + defaultMessage: '{icon} Previous page', + description: 'Pagination: previous page link', + }, + { icon: '←' } + )} + </a> + </Link> + </li> + ); + }; + + const getNextPageItem = () => { + return ( + <li className={styles.item}> + <Link href={`${baseUrl}/page/${currentPage + 1}`}> + <a className={styles.link}> + {intl.formatMessage( + { + defaultMessage: 'Next page {icon}', + description: 'Pagination: Next page link', + }, + { icon: '→' } + )} + </a> + </Link> + </li> + ); + }; + + const getPages = () => { + const pages = []; + for (let i = 1; i <= totalPages; i++) { + if (i === currentPage) { + pages.push({ + id: `page-${i}`, + link: ( + <span className={`${styles.link} ${styles['link--current']}`}> + {intl.formatMessage( + { + defaultMessage: '<a11y>Page </a11y>{number}', + description: 'Pagination: page number', + }, + { + number: i, + a11y: (chunks: string) => ( + <span className="screen-reader-text">{chunks}</span> + ), + } + )} + </span> + ), + }); + } else { + pages.push({ + id: `page-${i}`, + link: ( + <Link href={`${baseUrl}/page/${i}`}> + <a className={styles.link}> + {intl.formatMessage( + { + defaultMessage: '<a11y>Page </a11y>{number}', + description: 'Pagination: page number', + }, + { + number: i, + a11y: (chunks: string) => ( + <span className="screen-reader-text">{chunks}</span> + ), + } + )} + </a> + </Link> + ), + }); + } + } + + return pages; + }; + + const getItems = () => { + const pages = getPages(); + + return pages.map((page) => ( + <li key={page.id} className={styles.item}> + {page.link} + </li> + )); + }; + + return ( + <nav className={styles.wrapper} aria-labelledby="pagination-title"> + <h2 id="pagination-title" className="screen-reader-text"> + {intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'Pagination: pagination title', + })} + </h2> + <ul className={styles.list}> + {hasPreviousPage && getPreviousPageItem()} + {getItems()} + {hasNextPage && getNextPageItem()} + </ul> + </nav> + ); +}; + +export default Pagination; diff --git a/src/i18n/en.json b/src/i18n/en.json index 4928516..f6e48ae 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -107,6 +107,10 @@ "defaultMessage": "Please fill the form to contact me.", "description": "ContactPage: page introduction" }, + "8w+jnD": { + "defaultMessage": "Blog - Page {number} - {websiteName}", + "description": "BlogPage: SEO - Page title" + }, "9kx83j": { "defaultMessage": "Close help", "description": "Tooltip: button title" @@ -139,6 +143,10 @@ "defaultMessage": "Others formats", "description": "CVPage: cv preview widget title" }, + "BAkq7J": { + "defaultMessage": "Pagination", + "description": "Pagination: pagination title" + }, "C+r/LF": { "defaultMessage": "Updated on:", "description": "Dates: update date meta label" @@ -223,10 +231,6 @@ "defaultMessage": "Comment", "description": "CommentForm: Comment field label" }, - "JPh168": { - "defaultMessage": "Javascript is required to load more posts.", - "description": "BlogPage: noscript tag" - }, "JeYOeA": { "defaultMessage": "Sidebar", "description": "ArticlePage: right sidebar aria-label" @@ -331,6 +335,10 @@ "defaultMessage": "Blog", "description": "BlogPage: breadcrumb item" }, + "R4yaW6": { + "defaultMessage": "Next page {icon}", + "description": "Pagination: Next page link" + }, "RZzx/4": { "defaultMessage": "Javascript is required to use the table of contents.", "description": "ToC: noscript tag" @@ -355,6 +363,10 @@ "defaultMessage": "Subscribe", "description": "HomePage: RSS feed subscription text" }, + "TSXPzr": { + "defaultMessage": "<a11y>Page </a11y>{number}", + "description": "Pagination: page number" + }, "TfU6Qm": { "defaultMessage": "Search", "description": "SearchPage: breadcrumb item" @@ -455,6 +467,10 @@ "defaultMessage": "{starsCount, plural, =0 {0 stars on Github} one {# star on Github} other {# stars on Github}}", "description": "ProjectSummary: technologies list label" }, + "aMFqPH": { + "defaultMessage": "{icon} Previous page", + "description": "Pagination: previous page link" + }, "akSutM": { "defaultMessage": "Projects", "description": "MainNav: projects link" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3411667..6f8ce41 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -107,6 +107,10 @@ "defaultMessage": "Veuillez remplir le formulaire pour me contacter.", "description": "ContactPage: page introduction" }, + "8w+jnD": { + "defaultMessage": "Blog - Page {number} - {websiteName}", + "description": "BlogPage: SEO - Page title" + }, "9kx83j": { "defaultMessage": "Fermer l'aide", "description": "Tooltip: button title" @@ -139,6 +143,10 @@ "defaultMessage": "Autres formats", "description": "CVPage: cv preview widget title" }, + "BAkq7J": { + "defaultMessage": "Pagination", + "description": "Pagination: pagination title" + }, "C+r/LF": { "defaultMessage": "Mis à jour le :", "description": "Dates: update date meta label" @@ -223,10 +231,6 @@ "defaultMessage": "Commentaire", "description": "CommentForm: Comment field label" }, - "JPh168": { - "defaultMessage": "Javascript est nécessaire pour charger plus d'articles.", - "description": "BlogPage: noscript tag" - }, "JeYOeA": { "defaultMessage": "Barre latérale", "description": "ArticlePage: right sidebar aria-label" @@ -331,6 +335,10 @@ "defaultMessage": "Blog", "description": "BlogPage: breadcrumb item" }, + "R4yaW6": { + "defaultMessage": "Page suivante {icon}", + "description": "Pagination: Next page link" + }, "RZzx/4": { "defaultMessage": "Javascript est nécessaire pour utiliser la table des matières.", "description": "ToC: noscript tag" @@ -355,6 +363,10 @@ "defaultMessage": "Vous abonner", "description": "HomePage: RSS feed subscription text" }, + "TSXPzr": { + "defaultMessage": "<a11y>Page </a11y>{number}", + "description": "Pagination: page number" + }, "TfU6Qm": { "defaultMessage": "Recherche", "description": "SearchPage: breadcrumb item" @@ -455,6 +467,10 @@ "defaultMessage": "{starsCount, plural, =0 {0 étoile sur Github} one {# étoile sur Github} other {# étoiles sur Github}}", "description": "ProjectSummary: technologies list label" }, + "aMFqPH": { + "defaultMessage": "{icon} Page précédente", + "description": "Pagination: previous page link" + }, "akSutM": { "defaultMessage": "Projets", "description": "MainNav: projects link" diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 543fad9..366fc28 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,5 +1,6 @@ import { Button } from '@components/Buttons'; import { getLayout } from '@components/Layouts/Layout'; +import Pagination from '@components/Pagination/Pagination'; import PaginationCursor from '@components/PaginationCursor/PaginationCursor'; import PostHeader from '@components/PostHeader/PostHeader'; import PostsList from '@components/PostsList/PostsList'; @@ -29,12 +30,17 @@ import useSWRInfinite from 'swr/infinite'; const Blog: NextPageWithLayout<BlogPageProps> = ({ allThematics, allTopics, - firstPosts, + posts, totalPosts, }) => { const intl = useIntl(); const lastPostRef = useRef<HTMLSpanElement>(null); const router = useRouter(); + const [isMounted, setIsMounted] = useState<boolean>(false); + + useEffect(() => { + if (typeof window !== undefined) setIsMounted(true); + }, []); const getKey = (pageIndex: number, previousData: PostsListData) => { if (previousData && !previousData.posts) return null; @@ -50,7 +56,7 @@ const Blog: NextPageWithLayout<BlogPageProps> = ({ const { data, error, size, setSize } = useSWRInfinite( getKey, getPublishedPosts, - { fallbackData: [firstPosts] } + { fallbackData: [posts] } ); const [totalPostsCount, setTotalPostsCount] = useState<number>(totalPosts); @@ -171,31 +177,28 @@ const Blog: NextPageWithLayout<BlogPageProps> = ({ <PostHeader title={title} meta={{ results: totalPostsCount }} /> <div className={styles.body}> {getPostsList()} - {hasNextPage && ( - <> - <PaginationCursor - current={loadedPostsCount} - total={totalPostsCount} - /> - <Button - isDisabled={isLoadingMore} - clickHandler={loadMorePosts} - position="center" - spacing={true} - > - {intl.formatMessage({ - defaultMessage: 'Load more?', - description: 'BlogPage: load more text', - })} - </Button> - <noscript> - {intl.formatMessage({ - defaultMessage: 'Javascript is required to load more posts.', - description: 'BlogPage: noscript tag', - })} - </noscript> - </> - )} + {hasNextPage && + (isMounted ? ( + <> + <PaginationCursor + current={loadedPostsCount} + total={totalPostsCount} + /> + <Button + isDisabled={isLoadingMore} + clickHandler={loadMorePosts} + position="center" + spacing={true} + > + {intl.formatMessage({ + defaultMessage: 'Load more?', + description: 'BlogPage: load more text', + })} + </Button> + </> + ) : ( + <Pagination baseUrl="/blog" total={totalPostsCount} /> + ))} </div> <Sidebar position="right" @@ -246,8 +249,8 @@ export const getStaticProps: GetStaticProps = async ( allThematics, allTopics, breadcrumbTitle, - firstPosts, locale, + posts: firstPosts, totalPosts, translation, }, diff --git a/src/pages/blog/page/[id].tsx b/src/pages/blog/page/[id].tsx new file mode 100644 index 0000000..3be058b --- /dev/null +++ b/src/pages/blog/page/[id].tsx @@ -0,0 +1,195 @@ +import { getLayout } from '@components/Layouts/Layout'; +import Pagination from '@components/Pagination/Pagination'; +import PostHeader from '@components/PostHeader/PostHeader'; +import PostsList from '@components/PostsList/PostsList'; +import Sidebar from '@components/Sidebar/Sidebar'; +import { ThematicsList, TopicsList } from '@components/Widgets'; +import { + getAllThematics, + getAllTopics, + getEndCursor, + getPostsTotal, + getPublishedPosts, +} from '@services/graphql/queries'; +import { NextPageWithLayout } from '@ts/types/app'; +import { BlogPageProps } from '@ts/types/blog'; +import { settings } from '@utils/config'; +import { getIntlInstance, loadTranslation } from '@utils/helpers/i18n'; +import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; +import { useIntl } from 'react-intl'; +import { Blog, Graph, WebPage } from 'schema-dts'; +import styles from '@styles/pages/Page.module.scss'; +import { getFormattedPageNumbers } from '@utils/helpers/format'; +import { useEffect } from 'react'; + +const BlogPage: NextPageWithLayout<BlogPageProps> = ({ + allThematics, + allTopics, + posts, + totalPosts, +}) => { + const intl = useIntl(); + const router = useRouter(); + const pageNumber = Number(router.query.id); + + useEffect(() => { + if (router.query.id === '1') router.push('/blog'); + }, [router]); + + const pageTitle = intl.formatMessage( + { + defaultMessage: `Blog - Page {number} - {websiteName}`, + description: 'BlogPage: SEO - Page title', + }, + { number: pageNumber, websiteName: settings.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', + }, + { websiteName: settings.name } + ); + const pageUrl = `${settings.url}${router.asPath}`; + + const webpageSchema: WebPage = { + '@id': `${pageUrl}`, + '@type': 'WebPage', + breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, + name: pageTitle, + description: pageDescription, + inLanguage: settings.locales.defaultLocale, + reviewedBy: { '@id': `${settings.url}/#branding` }, + url: `${settings.url}`, + isPartOf: { + '@id': `${settings.url}`, + }, + }; + + const blogSchema: Blog = { + '@id': `${settings.url}/#blog`, + '@type': 'Blog', + author: { '@id': `${settings.url}/#branding` }, + creator: { '@id': `${settings.url}/#branding` }, + editor: { '@id': `${settings.url}/#branding` }, + inLanguage: settings.locales.defaultLocale, + license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', + mainEntityOfPage: { '@id': `${pageUrl}` }, + }; + + const schemaJsonLd: Graph = { + '@context': 'https://schema.org', + '@graph': [webpageSchema, blogSchema], + }; + + const title = intl.formatMessage({ + defaultMessage: 'Blog', + description: 'BlogPage: page title', + }); + + return ( + <> + <Head> + <title>{pageTitle}</title> + <meta name="description" content={pageDescription} /> + <meta property="og:url" content={`${pageUrl}`} /> + <meta property="og:type" content="website" /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={pageDescription} /> + </Head> + <Script + id="schema-blog" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <article + id="blog" + className={`${styles.article} ${styles['article--no-comments']}`} + > + <PostHeader title={title} meta={{ results: totalPosts }} /> + <div className={styles.body}> + <PostsList data={[posts]} showYears={true} /> + <Pagination baseUrl="/blog" total={totalPosts} /> + </div> + <Sidebar + position="right" + title={intl.formatMessage({ + defaultMessage: 'Filter by:', + description: 'BlogPage: sidebar title', + })} + > + <ThematicsList + initialData={allThematics} + title={intl.formatMessage({ + defaultMessage: 'Thematics', + description: 'BlogPage: thematics list widget title', + })} + /> + <TopicsList + initialData={allTopics} + title={intl.formatMessage({ + defaultMessage: 'Topics', + description: 'BlogPage: topics list widget title', + })} + /> + </Sidebar> + </article> + </> + ); +}; + +BlogPage.getLayout = getLayout; + +export const getStaticProps: GetStaticProps = async ( + context: GetStaticPropsContext +) => { + const intl = await getIntlInstance(); + const breadcrumbTitle = intl.formatMessage({ + defaultMessage: 'Blog', + description: 'BlogPage: breadcrumb item', + }); + const { locale, params } = context; + const queriedPageNumber = params ? Number(params.id) : 1; + const queriedPostsNumber = settings.postsPerPage * queriedPageNumber; + const endCursor = + queriedPostsNumber === 1 + ? undefined + : await getEndCursor({ first: queriedPostsNumber }); + const posts = await getPublishedPosts({ + first: settings.postsPerPage, + after: endCursor, + }); + const totalPosts = await getPostsTotal(); + const allThematics = await getAllThematics(); + const allTopics = await getAllTopics(); + const translation = await loadTranslation(locale); + + return { + props: { + allThematics, + allTopics, + breadcrumbTitle, + locale, + posts, + totalPosts, + translation, + }, + }; +}; + +export default BlogPage; + +export const getStaticPaths: GetStaticPaths = async () => { + const totalPosts = await getPostsTotal(); + const totalPages = Math.floor(totalPosts / settings.postsPerPage); + const paths = getFormattedPageNumbers(totalPages); + + return { + paths, + fallback: true, + }; +}; diff --git a/src/services/graphql/queries.ts b/src/services/graphql/queries.ts index e56590f..8dd8563 100644 --- a/src/services/graphql/queries.ts +++ b/src/services/graphql/queries.ts @@ -1,6 +1,11 @@ import { Slug } from '@ts/types/app'; import { Article, PostBy, TotalArticles } from '@ts/types/articles'; -import { AllPostsSlug, PostsList, RawPostsList } from '@ts/types/blog'; +import { + AllPostsSlug, + LastPostCursor, + PostsList, + RawPostsList, +} from '@ts/types/blog'; import { Comment, CommentsByPostId } from '@ts/types/comments'; import { AllTopics, @@ -510,3 +515,25 @@ export const getAllThematics = async (): Promise<ThematicPreview[]> => { const response = await fetchApi<AllThematics>(query, null); return response.thematics.nodes; }; + +export const getEndCursor = async ({ + first = 10, +}: { + first: number; +}): Promise<string> => { + const query = gql` + query EndCursorAfter($first: Int) { + posts(first: $first) { + pageInfo { + hasNextPage + endCursor + } + } + } + `; + + const variables = { first }; + const response = await fetchApi<LastPostCursor>(query, variables); + + return response.posts.pageInfo.endCursor; +}; diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts index 444733c..4243762 100644 --- a/src/ts/types/app.ts +++ b/src/ts/types/app.ts @@ -3,7 +3,7 @@ import { AppProps } from 'next/app'; import { ImageProps } from 'next/image'; import { ReactElement, ReactNode } from 'react'; import { PostBy, TotalArticles } from './articles'; -import { AllPostsSlug, RawPostsList } from './blog'; +import { AllPostsSlug, LastPostCursor, RawPostsList } from './blog'; import { CommentData, CommentsByPostId, CreateComment } from './comments'; import { ContactData, SendEmail } from './contact'; import { @@ -39,6 +39,8 @@ export type VariablesType<T> = T extends PostBy | TopicBy | ThematicBy ? { id: number } : T extends CreateComment ? CommentData + : T extends LastPostCursor + ? { first: number } : T extends SendEmail ? ContactData : null; @@ -51,6 +53,7 @@ export type RequestType = | AllThematicsSlug | CommentsByPostId | CreateComment + | LastPostCursor | PostBy | RawPostsList | SendEmail @@ -109,6 +112,10 @@ export type PageInfo = { total: number; }; +export type ParamsIds = { + params: { id: string }; +}; + export type ParamsSlug = { params: { slug: string }; }; diff --git a/src/ts/types/blog.ts b/src/ts/types/blog.ts index 8b48264..05bdd1f 100644 --- a/src/ts/types/blog.ts +++ b/src/ts/types/blog.ts @@ -19,6 +19,14 @@ export type RawPostsList = { }; }; +export type LastPostCursor = { + posts: { + pageInfo: { + endCursor: string; + }; + }; +}; + export type AllPostsSlug = { posts: { nodes: Slug[]; @@ -28,6 +36,6 @@ export type AllPostsSlug = { export type BlogPageProps = { allThematics: ThematicPreview[]; allTopics: TopicPreview[]; - firstPosts: PostsList; + posts: PostsList; totalPosts: number; }; diff --git a/src/utils/helpers/format.ts b/src/utils/helpers/format.ts index 9c6f266..71455b6 100644 --- a/src/utils/helpers/format.ts +++ b/src/utils/helpers/format.ts @@ -1,4 +1,4 @@ -import { ParamsSlug, Slug } from '@ts/types/app'; +import { ParamsIds, ParamsSlug, Slug } from '@ts/types/app'; import { Article, ArticlePreview, @@ -293,3 +293,18 @@ export const getFormattedPaths = (array: Slug[]): ParamsSlug[] => { return { params: { slug: object.slug } }; }); }; + +/** + * Convert a number of pages to an array of params with ids. + * @param {number} totalPages - The total pages. + * @returns {ParamsIds} An array of params with ids. + */ +export const getFormattedPageNumbers = (totalPages: number): ParamsIds[] => { + const paths = []; + + for (let i = 1; i <= totalPages; i++) { + paths.push({ params: { id: `${i}` } }); + } + + return paths; +}; |
