From 5b6639a3cf9b6c63045cb82e6ef1a43b0742c367 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Wed, 9 Mar 2022 00:38:02 +0100 Subject: feat: provide pagination for users with js disabled (#13) * chore: add a Pagination component * chore: add blog pages * chore: fallback to page number based navigation if JS disabled * chore: update translation --- src/components/Pagination/Pagination.module.scss | 92 +++++++++++ src/components/Pagination/Pagination.tsx | 131 +++++++++++++++ src/i18n/en.json | 24 ++- src/i18n/fr.json | 24 ++- src/pages/blog/index.tsx | 59 +++---- src/pages/blog/page/[id].tsx | 195 +++++++++++++++++++++++ src/services/graphql/queries.ts | 29 +++- src/ts/types/app.ts | 9 +- src/ts/types/blog.ts | 10 +- src/utils/helpers/format.ts | 17 +- 10 files changed, 550 insertions(+), 40 deletions(-) create mode 100644 src/components/Pagination/Pagination.module.scss create mode 100644 src/components/Pagination/Pagination.tsx create mode 100644 src/pages/blog/page/[id].tsx 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 ( +
  • + + + {intl.formatMessage( + { + defaultMessage: '{icon} Previous page', + description: 'Pagination: previous page link', + }, + { icon: '←' } + )} + + +
  • + ); + }; + + const getNextPageItem = () => { + return ( +
  • + + + {intl.formatMessage( + { + defaultMessage: 'Next page {icon}', + description: 'Pagination: Next page link', + }, + { icon: '→' } + )} + + +
  • + ); + }; + + const getPages = () => { + const pages = []; + for (let i = 1; i <= totalPages; i++) { + if (i === currentPage) { + pages.push({ + id: `page-${i}`, + link: ( + + {intl.formatMessage( + { + defaultMessage: 'Page {number}', + description: 'Pagination: page number', + }, + { + number: i, + a11y: (chunks: string) => ( + {chunks} + ), + } + )} + + ), + }); + } else { + pages.push({ + id: `page-${i}`, + link: ( + + + {intl.formatMessage( + { + defaultMessage: 'Page {number}', + description: 'Pagination: page number', + }, + { + number: i, + a11y: (chunks: string) => ( + {chunks} + ), + } + )} + + + ), + }); + } + } + + return pages; + }; + + const getItems = () => { + const pages = getPages(); + + return pages.map((page) => ( +
  • + {page.link} +
  • + )); + }; + + return ( + + ); +}; + +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": "Page {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": "Page {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 = ({ allThematics, allTopics, - firstPosts, + posts, totalPosts, }) => { const intl = useIntl(); const lastPostRef = useRef(null); const router = useRouter(); + const [isMounted, setIsMounted] = useState(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 = ({ const { data, error, size, setSize } = useSWRInfinite( getKey, getPublishedPosts, - { fallbackData: [firstPosts] } + { fallbackData: [posts] } ); const [totalPostsCount, setTotalPostsCount] = useState(totalPosts); @@ -171,31 +177,28 @@ const Blog: NextPageWithLayout = ({
    {getPostsList()} - {hasNextPage && ( - <> - - - - - )} + {hasNextPage && + (isMounted ? ( + <> + + + + ) : ( + + ))}
    = ({ + 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 ( + <> + + {pageTitle} + + + + + + +