diff options
Diffstat (limited to 'src/pages')
| -rw-r--r-- | src/pages/404.tsx | 165 | ||||
| -rw-r--r-- | src/pages/_app.tsx | 10 | ||||
| -rw-r--r-- | src/pages/article/[slug].tsx | 424 | ||||
| -rw-r--r-- | src/pages/blog/index.tsx | 366 | ||||
| -rw-r--r-- | src/pages/blog/page/[id].tsx | 205 | ||||
| -rw-r--r-- | src/pages/blog/page/[number].tsx | 237 | ||||
| -rw-r--r-- | src/pages/contact.tsx | 231 | ||||
| -rw-r--r-- | src/pages/cv.tsx | 276 | ||||
| -rw-r--r-- | src/pages/index.tsx | 327 | ||||
| -rw-r--r-- | src/pages/mentions-legales.tsx | 185 | ||||
| -rw-r--r-- | src/pages/projet/[slug].tsx | 186 | ||||
| -rw-r--r-- | src/pages/projets.tsx | 128 | ||||
| -rw-r--r-- | src/pages/projets/[slug].tsx | 241 | ||||
| -rw-r--r-- | src/pages/projets/index.tsx | 123 | ||||
| -rw-r--r-- | src/pages/recherche/index.tsx | 348 | ||||
| -rw-r--r-- | src/pages/sujet/[slug].tsx | 360 | ||||
| -rw-r--r-- | src/pages/thematique/[slug].tsx | 331 |
17 files changed, 2173 insertions, 1970 deletions
diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 24c6951..c3a5cac 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -1,30 +1,89 @@ -import { getLayout } from '@components/Layouts/Layout'; -import PostHeader from '@components/PostHeader/PostHeader'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { settings } from '@utils/config'; -import { getIntlInstance, loadTranslation } from '@utils/helpers/i18n'; -import { GetStaticProps, GetStaticPropsContext } from 'next'; +import Link from '@components/atoms/links/link'; +import SearchForm from '@components/organisms/forms/search-form'; +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 { + getThematicsPreview, + getTotalThematics, +} from '@services/graphql/thematics'; +import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics'; +import { type NextPageWithLayout } from '@ts/types/app'; +import { + type RawThematicPreview, + type RawTopicPreview, +} from '@ts/types/raw-data'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getLinksListItems, + getPageLinkFromRawData, +} from '@utils/helpers/pages'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticProps } from 'next'; import Head from 'next/head'; -import Link from 'next/link'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { ReactNode } from 'react'; +import { useIntl } from 'react-intl'; -const Error404: NextPageWithLayout = () => { - const intl = useIntl(); +type Error404PageProps = { + thematicsList: RawThematicPreview[]; + topicsList: RawTopicPreview[]; + translation: Messages; +}; +/** + * Error 404 page. + */ +const Error404Page: NextPageWithLayout<Error404PageProps> = ({ + thematicsList, + topicsList, +}) => { + const intl = useIntl(); + const { website } = useSettings(); + const title = intl.formatMessage({ + defaultMessage: 'Page not found', + description: 'Error404Page: page title', + id: 'KnWeKh', + }); + const body = intl.formatMessage( + { + defaultMessage: + 'Sorry, it seems that the page your are looking for does not exist. If you think this path should work, feel free to <link>contact me</link> with the necessary information so that I can fix the problem.', + id: '9sGNKq', + description: 'Error404Page: page body', + }, + { + link: (chunks: ReactNode) => <Link href="/contact">{chunks}</Link>, + } + ); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/404`, + }); const pageTitle = intl.formatMessage( { defaultMessage: 'Error 404: Page not found - {websiteName}', description: '404Page: SEO - Page title', id: '310o3F', }, - { websiteName: settings.name } + { websiteName: website.name } ); const pageDescription = intl.formatMessage({ defaultMessage: 'Page not found.', description: '404Page: SEO - Meta description', id: '48Ww//', }); + const thematicsListTitle = intl.formatMessage({ + defaultMessage: 'Thematics', + description: 'Error404Page: thematics list widget title', + id: 'HohQPh', + }); + + const topicsListTitle = intl.formatMessage({ + defaultMessage: 'Topics', + description: 'Error404Page: topics list widget title', + id: 'GVpTIl', + }); return ( <> @@ -32,54 +91,64 @@ const Error404: NextPageWithLayout = () => { <title>{pageTitle}</title> <meta name="description" content={pageDescription} /> </Head> - <div className={`${styles.article} ${styles['article--no-comments']}`}> - <PostHeader - title={intl.formatMessage({ - defaultMessage: 'Page not found', - description: '404Page: page title', - id: 'OccTWi', + <PageLayout + title={title} + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + widgets={[ + <LinksListWidget + key="thematics-list" + items={getLinksListItems( + thematicsList.map((thematic) => + getPageLinkFromRawData(thematic, 'thematic') + ) + )} + title={thematicsListTitle} + level={2} + />, + <LinksListWidget + key="topics-list" + items={getLinksListItems( + topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) + )} + title={topicsListTitle} + level={2} + />, + ]} + > + {body} + <p> + {intl.formatMessage({ + defaultMessage: 'You can also try a search:', + description: 'Error404Page: try a search message', + id: 'XKy7rx', })} - /> - <div className={styles.body}> - <FormattedMessage - defaultMessage="Sorry, it seems that the page your are looking for does not exist. If you think this path should work, feel free to <link>contact me</link> with the necessary information so that I can fix the problem." - description="404Page: page body" - id="ZWh78Y" - values={{ - link: (chunks: string) => ( - <Link href="/contact/"> - <a>{chunks}</a> - </Link> - ), - }} - /> - </div> - </div> + </p> + <SearchForm hideLabel={true} searchPage="/recherche/" /> + </PageLayout> </> ); }; -Error404.getLayout = getLayout; +Error404Page.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const intl = await getIntlInstance(); - const breadcrumbTitle = intl.formatMessage({ - defaultMessage: 'Error 404', - description: '404Page: breadcrumb item', - id: 'ywkCsK', - }); - const { locale } = context; +export const getStaticProps: GetStaticProps<Error404PageProps> = async ({ + locale, +}) => { + 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: { - breadcrumbTitle, - locale, + thematicsList: thematics.edges.map((edge) => edge.node), + topicsList: topics.edges.map((edge) => edge.node), translation, }, }; }; -export default Error404; +export default Error404Page; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 84c2469..5bc9f85 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,4 +1,4 @@ -import { AppPropsWithLayout } from '@ts/types/app'; +import { type AppPropsWithLayout } from '@ts/types/app'; import { settings } from '@utils/config'; import { AckeeProvider } from '@utils/providers/ackee'; import { PrismThemeProvider } from '@utils/providers/prism-theme'; @@ -7,11 +7,11 @@ import { useRouter } from 'next/router'; import { IntlProvider } from 'react-intl'; import '../styles/globals.scss'; -const MyApp = ({ Component, pageProps }: AppPropsWithLayout) => { +const App = ({ Component, pageProps }: AppPropsWithLayout) => { const { locale, defaultLocale } = useRouter(); const appLocale: string = locale || settings.locales.defaultLocale; - const getLayout = Component.getLayout ?? ((page) => page); + return ( <AckeeProvider domain={settings.ackee.url} siteId={settings.ackee.siteId}> <IntlProvider @@ -25,7 +25,7 @@ const MyApp = ({ Component, pageProps }: AppPropsWithLayout) => { enableSystem={true} > <PrismThemeProvider> - {getLayout(<Component {...pageProps} />)} + {getLayout(<Component {...pageProps} />, {})} </PrismThemeProvider> </ThemeProvider> </IntlProvider> @@ -33,4 +33,4 @@ const MyApp = ({ Component, pageProps }: AppPropsWithLayout) => { ); }; -export default MyApp; +export default App; diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index 27a6f7b..ea679ab 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -1,286 +1,262 @@ -import CommentForm from '@components/CommentForm/CommentForm'; -import CommentsList from '@components/CommentsList/CommentsList'; -import { getLayout } from '@components/Layouts/Layout'; -import PostFooter from '@components/PostFooter/PostFooter'; -import PostHeader from '@components/PostHeader/PostHeader'; -import Sidebar from '@components/Sidebar/Sidebar'; -import Spinner from '@components/Spinner/Spinner'; -import { Sharing, ToC } from '@components/Widgets'; +import ButtonLink from '@components/atoms/buttons/button-link'; +import Link from '@components/atoms/links/link'; +import Spinner from '@components/atoms/loaders/spinner'; +import ResponsiveImage from '@components/molecules/images/responsive-image'; +import Sharing from '@components/organisms/widgets/sharing'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; import { - getAllPostsSlug, - getCommentsByPostId, - getPostBySlug, -} from '@services/graphql/queries'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { ArticleMeta, ArticleProps } from '@ts/types/articles'; -import { PrismDefaultPlugins, PrismPlugins } from '@ts/types/prism'; -import { settings } from '@utils/config'; -import { getFormattedPaths } from '@utils/helpers/format'; -import { loadTranslation } from '@utils/helpers/i18n'; -import { addPrismClasses } from '@utils/helpers/prism'; -import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; + getAllArticlesSlugs, + getArticleBySlug, +} from '@services/graphql/articles'; +import { getPostComments } from '@services/graphql/comments'; +import styles from '@styles/pages/article.module.scss'; +import { + type Article, + type Comment, + type NextPageWithLayout, +} from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getBlogSchema, + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import usePrism, { type OptionalPrismPlugin } from '@utils/hooks/use-prism'; +import useReadingTime from '@utils/hooks/use-reading-time'; +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 Prism from 'prismjs'; import { ParsedUrlQuery } from 'querystring'; -import { useCallback, useEffect, useMemo } from 'react'; +import { HTMLAttributes } from 'react'; import { useIntl } from 'react-intl'; -import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts'; +import useSWR from 'swr'; + +type ArticlePageProps = { + comments: Comment[]; + post: Article; + slug: string; + translation: Messages; +}; -const SingleArticle: NextPageWithLayout<ArticleProps> = ({ +/** + * Article page. + */ +const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ comments, post, + slug, }) => { + const { isFallback } = useRouter(); const intl = useIntl(); - const router = useRouter(); + const { data: article } = useSWR(() => slug, getArticleBySlug, { + fallbackData: post, + }); + const { data: commentsData } = useSWR(() => id, getPostComments, { + fallbackData: comments, + }); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title: article?.title || '', + url: `/article/${slug}`, + }); + const readingTime = useReadingTime(article?.meta.wordsCount || 0, true); + const { website } = useSettings(); + const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers']; + const { attributes, className } = usePrism({ plugins: prismPlugins }); - const loadPrismPlugins = useCallback( - async (prismPlugins: (PrismDefaultPlugins | PrismPlugins)[]) => { - for (const plugin of prismPlugins) { - try { - if (plugin === 'color-scheme') { - await import(`@utils/plugins/prism-${plugin}`); - } else { - await import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`); + if (isFallback) return <Spinner />; - if (plugin === 'autoloader') - Prism.plugins.autoloader.languages_path = '/prism/'; - } - } catch (error) { - console.error('Article: an error occurred with Prism.'); - console.error(error); - } - } - }, - [] - ); + const { content, id, intro, meta, title } = article!; + const { author, commentsCount, cover, dates, seo, thematics, topics } = meta; - const plugins: (PrismDefaultPlugins | PrismPlugins)[] = useMemo( - () => [ - 'autoloader', - 'toolbar', - 'show-language', - 'copy-to-clipboard', - 'color-scheme', - 'command-line', - 'line-numbers', - 'match-braces', - 'normalize-whitespace', - ], - [] - ); + const headerMeta: PageLayoutProps['headerMeta'] = { + author: author?.name, + publication: { date: dates.publication }, + update: + dates.update && dates.publication !== dates.update + ? { date: dates.update } + : undefined, + readingTime, + thematics: + thematics && + thematics.map((thematic) => ( + <Link key={thematic.id} href={thematic.url}> + {thematic.name} + </Link> + )), + }; - useEffect(() => { - loadPrismPlugins(plugins).then(() => { - addPrismClasses(); - Prism.highlightAll(); - }); - }, [plugins, loadPrismPlugins]); + const footerMetaLabel = intl.formatMessage({ + defaultMessage: 'Read more articles about:', + description: 'ArticlePage: footer topics list label', + id: '50xc4o', + }); - if (router.isFallback) return <Spinner />; + const footerMeta: PageLayoutProps['footerMeta'] = { + custom: topics && { + label: footerMetaLabel, + value: topics.map((topic) => { + return ( + <ButtonLink key={topic.id} target={topic.url} className={styles.btn}> + {topic.logo && <ResponsiveImage {...topic.logo} />} {topic.name} + </ButtonLink> + ); + }), + }, + }; - const { - author, - commentCount, + const webpageSchema = getWebPageSchema({ + description: intro, + locale: website.locales.default, + slug, + title, + updateDate: dates.update, + }); + const blogSchema = getBlogSchema({ + isSinglePage: true, + locale: website.locales.default, + slug, + }); + const blogPostSchema = getSinglePageSchema({ + commentsCount, content, - databaseId, + cover: cover?.src, dates, - featuredImage, - info, - intro, - seo, - topics, - thematics, + description: intro, + id: 'article', + kind: 'post', + locale: website.locales.default, + slug, title, - } = post; - - const meta: ArticleMeta = { - author, - commentCount: commentCount || undefined, - dates, - readingTime: info.readingTime, - thematics, - wordsCount: info.wordsCount, - }; - - const articleUrl = `${settings.url}${router.asPath}`; + }); + const schemaJsonLd = getSchemaJson([ + webpageSchema, + blogSchema, + blogPostSchema, + ]); - const webpageSchema: WebPage = { - '@id': `${articleUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - lastReviewed: dates.update, - name: seo.title, - description: seo.metaDesc, - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${articleUrl}`, - isPartOf: { - '@id': `${settings.url}`, - }, - }; + const lineNumbersClassName = className + .replace('command-line', '') + .replace(/\s\s+/g, ' '); + const commandLineClassName = className + .replace('line-numbers', '') + .replace(/\s\s+/g, ' '); - const blogSchema: Blog = { - '@id': `${settings.url}/#blog`, - '@type': 'Blog', - blogPost: { '@id': `${settings.url}/#article` }, - isPartOf: { - '@id': `${articleUrl}`, - }, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - }; + /** + * Replace a string with Prism classnames and attributes. + * + * @param {string} str - The found string. + * @returns {string} The classes and attributes. + */ + const prismClassNameReplacer = (str: string): string => { + const wpBlockClassName = 'wp-block-code'; + const languageArray = str.match(/language-[^\s|"]+/); + const languageClassName = languageArray ? `${languageArray[0]}` : ''; - const publicationDate = new Date(dates.publication); - const updateDate = new Date(dates.update); + if ( + str.includes('command-line') || + (!str.includes('command-line') && str.includes('language-bash')) + ) { + return `class="${wpBlockClassName} ${commandLineClassName}${languageClassName}" tabindex="0" data-filter-output="#output#`; + } - const blogPostSchema: BlogPosting = { - '@id': `${settings.url}/#article`, - '@type': 'BlogPosting', - name: title, - description: intro, - articleBody: content, - author: { '@id': `${settings.url}/#branding` }, - commentCount: commentCount || undefined, - copyrightYear: publicationDate.getFullYear(), - creator: { '@id': `${settings.url}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate.toISOString(), - datePublished: publicationDate.toISOString(), - discussionUrl: `${articleUrl}/#comments`, - editor: { '@id': `${settings.url}/#branding` }, - headline: title, - image: featuredImage?.sourceUrl, - inLanguage: settings.locales.defaultLocale, - isPartOf: { - '@id': `${settings.url}/blog`, - }, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - mainEntityOfPage: { '@id': `${articleUrl}` }, - thumbnailUrl: featuredImage?.sourceUrl, + return `class="${wpBlockClassName} ${lineNumbersClassName}${languageClassName}" tabindex="0`; }; - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema, blogSchema, blogPostSchema], - }; + const contentWithPrismClasses = content.replaceAll( + /class="wp-block-code[^"]+/gm, + prismClassNameReplacer + ); - const copyText = intl.formatMessage({ - defaultMessage: 'Copy', - description: 'Prism: copy button text (no clicked)', - id: '/ly3AC', - }); - const copiedText = intl.formatMessage({ - defaultMessage: 'Copied!', - description: 'Prism: copy button text (clicked)', - id: 'OV9r1K', - }); - const errorText = intl.formatMessage({ - defaultMessage: 'Use Ctrl+c to copy', - description: 'Prism: error text', - id: 'z9qkcQ', - }); - const darkTheme = intl.formatMessage({ - defaultMessage: 'Dark Theme 🌙', - description: 'Prism: toggle dark theme button text', - id: 'nFMdWI', - }); - const lightTheme = intl.formatMessage({ - defaultMessage: 'Light Theme 🌞', - description: 'Prism: toggle light theme button text', - id: 'Ua2g2p', - }); + const pageUrl = `${website.url}${slug}`; return ( <> <Head> <title>{seo.title}</title> - <meta name="description" content={seo.metaDesc} /> - <meta property="og:url" content={`${articleUrl}`} /> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${pageUrl}`} /> <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> - <meta property="og:image" content={featuredImage?.sourceUrl} /> - <meta property="og:image:alt" content={featuredImage?.altText} /> </Head> <Script - id="schema-article" + id="schema-project" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <article - id="article" - className={styles.article} - data-prismjs-copy={copyText} - data-prismjs-copy-success={copiedText} - data-prismjs-copy-error={errorText} - data-prismjs-color-scheme-dark={darkTheme} - data-prismjs-color-scheme-light={lightTheme} + <PageLayout + allowComments={true} + bodyAttributes={{ + ...(attributes as HTMLAttributes<HTMLDivElement>), + }} + bodyClassName={styles.body} + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + comments={commentsData} + footerMeta={footerMeta} + headerMeta={headerMeta} + id={id as number} + intro={intro} + title={title} + withToC={true} + widgets={[ + <Sharing + key="sharing-widget" + className={styles.widget} + data={{ excerpt: intro, title, url: pageUrl }} + media={[ + 'diaspora', + 'email', + 'facebook', + 'journal-du-hacker', + 'linkedin', + 'twitter', + ]} + />, + ]} > - <PostHeader intro={intro} meta={meta} title={title} /> - <Sidebar - position="left" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'ArticlePage: ToC sidebar aria-label', - id: '9nhYRA', - })} - > - <ToC /> - </Sidebar> - <div - className={styles.body} - dangerouslySetInnerHTML={{ __html: content }} - ></div> - <PostFooter topics={topics} /> - <Sidebar - position="right" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Sidebar', - description: 'ArticlePage: right sidebar aria-label', - id: 'JeYOeA', - })} - > - <Sharing title={title} excerpt={intro} /> - </Sidebar> - <section id="comments" className={styles.comments}> - <CommentsList articleId={databaseId} comments={comments} /> - <CommentForm articleId={databaseId} /> - </section> - </article> + {contentWithPrismClasses} + </PageLayout> </> ); }; -SingleArticle.getLayout = getLayout; +ArticlePage.getLayout = (page) => getLayout(page, { useGrid: true }); interface PostParams extends ParsedUrlQuery { slug: string; } -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const { locale } = context; +export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({ + locale, + params, +}) => { + const post = await getArticleBySlug(params!.slug as PostParams['slug']); + const comments = await getPostComments(post.id as number); const translation = await loadTranslation(locale); - const { slug } = context.params as PostParams; - const post = await getPostBySlug(slug); - const comments = await getCommentsByPostId(post.databaseId); - const breadcrumbTitle = post.title; return { props: { - breadcrumbTitle, - comments, - post, + comments: JSON.parse(JSON.stringify(comments)), + post: JSON.parse(JSON.stringify(post)), + slug: post.slug, translation, }, }; }; export const getStaticPaths: GetStaticPaths = async () => { - const allSlugs = await getAllPostsSlug(); - const paths = getFormattedPaths(allSlugs); + const slugs = await getAllArticlesSlugs(); + const paths = slugs.map((slug) => { + return { params: { slug } }; + }); return { paths, @@ -288,4 +264,4 @@ export const getStaticPaths: GetStaticPaths = async () => { }; }; -export default SingleArticle; +export default ArticlePage; diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index b5ced07..3f7eefd 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -1,116 +1,79 @@ -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'; -import Sidebar from '@components/Sidebar/Sidebar'; -import Spinner from '@components/Spinner/Spinner'; -import { ThematicsList, TopicsList } from '@components/Widgets'; +import Notice from '@components/atoms/layout/notice'; +import PostsList 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 { getArticles, getTotalArticles } from '@services/graphql/articles'; import { - getAllThematics, - getAllTopics, - getPostsTotal, - getPublishedPosts, -} from '@services/graphql/queries'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { BlogPageProps, PostsList as PostsListData } from '@ts/types/blog'; + getThematicsPreview, + getTotalThematics, +} from '@services/graphql/thematics'; +import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics'; +import { type NextPageWithLayout } from '@ts/types/app'; +import { + type RawArticle, + type RawThematicPreview, + type RawTopicPreview, +} from '@ts/types/raw-data'; import { settings } from '@utils/config'; -import { getIntlInstance, loadTranslation } from '@utils/helpers/i18n'; -import { GetStaticProps, GetStaticPropsContext } from 'next'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getLinksListItems, + getPageLinkFromRawData, + getPostsList, +} from '@utils/helpers/pages'; +import { + getBlogSchema, + getSchemaJson, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import usePagination from '@utils/hooks/use-pagination'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; -import { useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; -import { Blog as BlogSchema, Graph, WebPage } from 'schema-dts'; -import useSWRInfinite from 'swr/infinite'; -const Blog: NextPageWithLayout<BlogPageProps> = ({ - allThematics, - allTopics, - posts, - totalPosts, +type BlogPageProps = { + articles: EdgesResponse<RawArticle>; + thematicsList: RawThematicPreview[]; + topicsList: RawTopicPreview[]; + totalArticles: number; + translation: Messages; +}; + +/** + * Blog index page. + */ +const BlogPage: NextPageWithLayout<BlogPageProps> = ({ + articles, + thematicsList, + topicsList, + totalArticles, }) => { 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; - - return pageIndex === 0 - ? { first: settings.postsPerPage } - : { - first: settings.postsPerPage, - after: previousData.pageInfo.endCursor, - }; - }; - - const { data, error, size, setSize } = useSWRInfinite( - getKey, - getPublishedPosts, - { fallbackData: [posts] } - ); - const [totalPostsCount, setTotalPostsCount] = useState<number>(totalPosts); - - useEffect(() => { - if (data) setTotalPostsCount(data[0].pageInfo.total); - }, [data]); - - const [loadedPostsCount, setLoadedPostsCount] = useState<number>( - settings.postsPerPage - ); - - useEffect(() => { - if (data && data.length > 0) { - const newCount = - settings.postsPerPage + - data[0].pageInfo.total - - data[data.length - 1].pageInfo.total; - setLoadedPostsCount(newCount); - } - }, [data]); - - const isLoadingInitialData = !data && !error; - const isLoadingMore: boolean = - isLoadingInitialData || - (size > 0 && data !== undefined && typeof data[size - 1] === 'undefined'); - - const hasNextPage = data && data[data.length - 1].pageInfo.hasNextPage; - - const loadMorePosts = () => { - if (lastPostRef.current) { - lastPostRef.current.focus(); - } - setSize(size + 1); - }; - - const getPostsList = () => { - if (error) - return intl.formatMessage({ - defaultMessage: 'Failed to load.', - description: 'BlogPage: failed to load text', - id: 'C/XGkH', - }); - if (!data) return <Spinner />; - - return <PostsList ref={lastPostRef} data={data} showYears={true} />; - }; + const title = intl.formatMessage({ + defaultMessage: 'Blog', + description: 'BlogPage: page title', + id: '7TbbIk', + }); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: '/blog', + }); + const { blog, website } = useSettings(); + const { asPath } = useRouter(); const pageTitle = intl.formatMessage( { defaultMessage: 'Blog: development, open source - {websiteName}', description: 'BlogPage: SEO - Page title', id: '+Y+tLK', }, - { websiteName: settings.name } + { websiteName: website.name } ); const pageDescription = intl.formatMessage( { @@ -119,44 +82,51 @@ const Blog: NextPageWithLayout<BlogPageProps> = ({ description: 'BlogPage: SEO - Meta description', id: '18h/t0', }, - { websiteName: settings.name } + { websiteName: website.name } ); - const pageUrl = `${settings.url}${router.asPath}`; - - const webpageSchema: WebPage = { - '@id': `${pageUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: pageTitle, + const webpageSchema = getWebPageSchema({ description: pageDescription, - inLanguage: settings.locales.defaultLocale, - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${settings.url}`, - isPartOf: { - '@id': `${settings.url}`, - }, - }; + locale: website.locales.default, + slug: asPath, + title, + }); + const blogSchema = getBlogSchema({ + isSinglePage: false, + locale: website.locales.default, + slug: asPath, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); + + const { + data, + error, + isLoadingInitialData, + isLoadingMore, + hasNextPage, + setSize, + } = usePagination<RawArticle>({ + fallbackData: [articles], + fetcher: getArticles, + perPage: blog.postsPerPage, + }); - const blogSchema: BlogSchema = { - '@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}` }, + /** + * Load more posts handler. + */ + const loadMore = () => { + setSize((prevSize) => prevSize + 1); }; - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema, blogSchema], - }; + const thematicsListTitle = intl.formatMessage({ + defaultMessage: 'Thematics', + description: 'BlogPage: thematics list widget title', + id: 'HriY57', + }); - const title = intl.formatMessage({ - defaultMessage: 'Blog', - description: 'BlogPage: page title', - id: '7TbbIk', + const topicsListTitle = intl.formatMessage({ + defaultMessage: 'Topics', + description: 'BlogPage: topics list widget title', + id: '2D9tB5', }); return ( @@ -164,7 +134,7 @@ const Blog: NextPageWithLayout<BlogPageProps> = ({ <Head> <title>{pageTitle}</title> <meta name="description" content={pageDescription} /> - <meta property="og:url" content={`${pageUrl}`} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> <meta property="og:type" content="website" /> <meta property="og:title" content={title} /> <meta property="og:description" content={pageDescription} /> @@ -174,96 +144,82 @@ const Blog: NextPageWithLayout<BlogPageProps> = ({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <article - id="blog" - className={`${styles.article} ${styles['article--no-comments']}`} + <PageLayout + title={title} + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + headerMeta={{ total: totalArticles }} + widgets={[ + <LinksListWidget + key="thematics-list" + items={getLinksListItems( + thematicsList.map((thematic) => + getPageLinkFromRawData(thematic, 'thematic') + ) + )} + title={thematicsListTitle} + level={2} + />, + <LinksListWidget + key="topics-list" + items={getLinksListItems( + topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) + )} + title={topicsListTitle} + level={2} + />, + ]} > - <PostHeader title={title} meta={{ results: totalPostsCount }} /> - <div className={styles.body}> - {getPostsList()} - {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', - id: 'Kqq2cm', - })} - </Button> - </> - ) : ( - <Pagination baseUrl="/blog" total={totalPostsCount} /> - ))} - </div> - <Sidebar - position="right" - title={intl.formatMessage({ - defaultMessage: 'Filter by:', - description: 'BlogPage: sidebar title', - id: 'KERk7L', - })} - > - <ThematicsList - initialData={allThematics} - title={intl.formatMessage({ - defaultMessage: 'Thematics', - description: 'BlogPage: thematics list widget title', - id: 'HriY57', - })} + {data && ( + <PostsList + baseUrl="/blog/page/" + byYear={true} + isLoading={isLoadingMore || isLoadingInitialData} + loadMore={loadMore} + posts={getPostsList(data)} + searchPage="/recherche/" + showLoadMoreBtn={hasNextPage} + total={totalArticles} /> - <TopicsList - initialData={allTopics} - title={intl.formatMessage({ - defaultMessage: 'Topics', - description: 'BlogPage: topics list widget title', - id: '2D9tB5', + )} + {error && ( + <Notice + kind="error" + message={intl.formatMessage({ + defaultMessage: 'Failed to load.', + description: 'BlogPage: failed to load text', + id: 'C/XGkH', })} /> - </Sidebar> - </article> + )} + </PageLayout> </> ); }; -Blog.getLayout = getLayout; +BlogPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const intl = await getIntlInstance(); - const breadcrumbTitle = intl.formatMessage({ - defaultMessage: 'Blog', - description: 'BlogPage: breadcrumb item', - id: 'R0eDmw', - }); - const firstPosts = await getPublishedPosts({ first: settings.postsPerPage }); - const totalPosts = await getPostsTotal(); - const allThematics = await getAllThematics(); - const allTopics = await getAllTopics(); - const { locale } = context; +export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ + locale, +}) => { + const articles = await getArticles({ first: settings.postsPerPage }); + 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: { - allThematics, - allTopics, - breadcrumbTitle, - locale, - posts: firstPosts, - totalPosts, + articles: JSON.parse(JSON.stringify(articles)), + thematicsList: thematics.edges.map((edge) => edge.node), + topicsList: topics.edges.map((edge) => edge.node), + totalArticles, translation, }, }; }; -export default Blog; +export default BlogPage; diff --git a/src/pages/blog/page/[id].tsx b/src/pages/blog/page/[id].tsx deleted file mode 100644 index 6c4d2f8..0000000 --- a/src/pages/blog/page/[id].tsx +++ /dev/null @@ -1,205 +0,0 @@ -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'; -import Spinner from '@components/Spinner/Spinner'; - -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]); - - if (router.isFallback) return <Spinner />; - - const pageTitle = intl.formatMessage( - { - defaultMessage: 'Blog - Page {number} - {websiteName}', - description: 'BlogPage: SEO - Page title', - id: '8w+jnD', - }, - { 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', - id: '18h/t0', - }, - { 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', - id: '7TbbIk', - }); - - 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', - id: 'KERk7L', - })} - > - <ThematicsList - initialData={allThematics} - title={intl.formatMessage({ - defaultMessage: 'Thematics', - description: 'BlogPage: thematics list widget title', - id: 'HriY57', - })} - /> - <TopicsList - initialData={allTopics} - title={intl.formatMessage({ - defaultMessage: 'Topics', - description: 'BlogPage: topics list widget title', - id: '2D9tB5', - })} - /> - </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', - id: 'R0eDmw', - }); - 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/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx new file mode 100644 index 0000000..1e1240a --- /dev/null +++ b/src/pages/blog/page/[number].tsx @@ -0,0 +1,237 @@ +import PostsList 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 { + getArticles, + getArticlesEndCursor, + getTotalArticles, +} from '@services/graphql/articles'; +import { + getThematicsPreview, + getTotalThematics, +} from '@services/graphql/thematics'; +import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics'; +import { 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, + getPostsList, +} from '@utils/helpers/pages'; +import { + getBlogSchema, + getSchemaJson, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +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'; + +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 webpageSchema = getWebPageSchema({ + description: pageDescription, + locale: website.locales.default, + slug: asPath, + title, + }); + const blogSchema = getBlogSchema({ + isSinglePage: false, + locale: website.locales.default, + slug: asPath, + }); + 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', + }); + + return ( + <> + <Head> + <title>{pageTitle}</title> + <meta name="description" content={pageDescription} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> + <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((thematic) => + getPageLinkFromRawData(thematic, 'thematic') + ) + )} + title={thematicsListTitle} + level={2} + />, + <LinksListWidget + key="topics-list" + items={getLinksListItems( + topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) + )} + title={topicsListTitle} + level={2} + />, + ]} + > + <PostsList + baseUrl="/blog/page/" + byYear={true} + pageNumber={pageNumber} + posts={getPostsList([articles])} + searchPage="/recherche/" + 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/contact.tsx b/src/pages/contact.tsx index 5934dd9..2392fe2 100644 --- a/src/pages/contact.tsx +++ b/src/pages/contact.tsx @@ -1,89 +1,124 @@ -import ContactForm from '@components/ContactForm/ContactForm'; -import { getLayout } from '@components/Layouts/Layout'; -import PostHeader from '@components/PostHeader/PostHeader'; -import Sidebar from '@components/Sidebar/Sidebar'; -import { SocialMedia } from '@components/Widgets'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { settings } from '@utils/config'; -import { getIntlInstance, loadTranslation } from '@utils/helpers/i18n'; -import { GetStaticProps, GetStaticPropsContext } from 'next'; +import Notice, { type NoticeKind } from '@components/atoms/layout/notice'; +import ContactForm, { + type ContactFormProps, +} from '@components/organisms/forms/contact-form'; +import SocialMedia from '@components/organisms/widgets/social-media'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout from '@components/templates/page/page-layout'; +import { meta } from '@content/pages/contact.mdx'; +import { sendMail } from '@services/graphql/contact'; +import styles from '@styles/pages/contact.module.scss'; +import { type NextPageWithLayout } from '@ts/types/app'; +import { loadTranslation } from '@utils/helpers/i18n'; +import { + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; -import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts'; const ContactPage: NextPageWithLayout = () => { + const { dates, intro, seo, title } = meta; const intl = useIntl(); - const router = useRouter(); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/contact`, + }); - const pageTitle = intl.formatMessage( - { - defaultMessage: 'Contact form - {websiteName}', - description: 'ContactPage: SEO - Page title', - id: 'Y3qRib', - }, - { websiteName: settings.name } - ); - const pageDescription = intl.formatMessage( - { - defaultMessage: - "Contact {websiteName} through its website. All you need to do it's to fill the contact form.", - description: 'ContactPage: SEO - Meta description', - id: 'OIffB4', - }, - { websiteName: settings.name } - ); - const pageUrl = `${settings.url}${router.asPath}`; - const title = intl.formatMessage({ - defaultMessage: 'Contact', - description: 'ContactPage: page title', - id: 'AN9iy7', + const socialMediaTitle = intl.formatMessage({ + defaultMessage: 'Find me elsewhere', + description: 'ContactPage: social media widget title', + id: 'Qh2CwH', + }); + + const { website } = useSettings(); + const { asPath } = useRouter(); + const webpageSchema = getWebPageSchema({ + description: seo.description, + locale: website.locales.default, + slug: asPath, + title: seo.title, + updateDate: dates.update, }); - const intro = intl.formatMessage({ - defaultMessage: 'Please fill the form to contact me.', - description: 'ContactPage: page introduction', - id: '8Ls2mD', + const contactSchema = getSinglePageSchema({ + dates, + description: intro, + id: 'contact', + kind: 'contact', + locale: website.locales.default, + slug: asPath, + title, }); + const schemaJsonLd = getSchemaJson([webpageSchema, contactSchema]); - const webpageSchema: WebPage = { - '@id': `${pageUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: pageTitle, - description: pageDescription, - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${pageUrl}`, - isPartOf: { - '@id': `${settings.url}`, - }, - }; + const widgets = [ + <SocialMedia + key="social-media" + title={socialMediaTitle} + level={2} + media={[ + { name: 'Github', url: 'https://github.com/ArmandPhilippot' }, + { name: 'Gitlab', url: 'https://gitlab.com/ArmandPhilippot' }, + { + name: 'LinkedIn', + url: 'https://www.linkedin.com/in/armandphilippot', + }, + ]} + />, + ]; - const contactSchema: ContactPageSchema = { - '@id': `${settings.url}/#contact`, - '@type': 'ContactPage', - name: title, - description: intro, - 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 [status, setStatus] = useState<NoticeKind>('info'); + const [statusMessage, setStatusMessage] = useState<string>(''); + + const submitMail: ContactFormProps['sendMail'] = async (data, reset) => { + const { email, message, name, subject } = data; + const messageHTML = message.replace(/\r?\n/g, '<br />'); + const body = `Message received from ${name} <${email}> on ${website.url}.<br /><br />${messageHTML}`; + const replyTo = `${name} <${email}>`; + const mailData = { + body, + clientMutationId: 'contact', + replyTo, + subject, + }; + const { message: mutationMessage, sent } = await sendMail(mailData); - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema, contactSchema], + if (sent) { + setStatus('success'); + setStatusMessage( + intl.formatMessage({ + defaultMessage: + 'Thanks. Your message was successfully sent. I will answer it as soon as possible.', + description: 'Contact: success message', + id: '3Pipok', + }) + ); + reset(); + } else { + const errorPrefix = intl.formatMessage({ + defaultMessage: 'An error occurred:', + description: 'Contact: error message', + id: 'TpyFZ6', + }); + const error = `${errorPrefix} ${mutationMessage}`; + setStatus('error'); + setStatusMessage(error); + } }; return ( <> <Head> - <title>{pageTitle}</title> - <meta name="description" content={pageDescription} /> - <meta property="og:url" content={`${pageUrl}`} /> + <title>{`${seo.title} - ${website.name}`}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> @@ -93,56 +128,36 @@ const ContactPage: NextPageWithLayout = () => { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <article - id="contact" - className={`${styles.article} ${styles['article--no-comments']}`} + <PageLayout + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + intro={intro} + title="Contact" + widgets={widgets} > - <PostHeader title={title} intro={intro} /> - <div className={styles.body}> - <p> - {intl.formatMessage({ - defaultMessage: 'All fields marked with * are required.', - description: 'ContactPage: required fields text', - id: 'txusHd', - })} - </p> - <ContactForm /> - </div> - <Sidebar position="right"> - <SocialMedia - title={intl.formatMessage({ - defaultMessage: 'Find me elsewhere', - description: 'ContactPage: social media widget title', - id: 'Qh2CwH', - })} - github={true} - gitlab={true} - linkedin={true} - /> - </Sidebar> - </article> + <ContactForm + sendMail={submitMail} + Notice={ + <Notice + kind={status} + message={statusMessage} + className={styles.notice} + /> + } + /> + </PageLayout> </> ); }; -ContactPage.getLayout = getLayout; +ContactPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const intl = await getIntlInstance(); - const breadcrumbTitle = intl.formatMessage({ - defaultMessage: 'Contact', - description: 'ContactPage: breadcrumb item', - id: 'CzTbM4', - }); - const { locale } = context; +export const getStaticProps: GetStaticProps = async ({ locale }) => { const translation = await loadTranslation(locale); return { props: { - breadcrumbTitle, - locale, translation, }, }; diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx index 71eb449..4686505 100644 --- a/src/pages/cv.tsx +++ b/src/pages/cv.tsx @@ -1,108 +1,164 @@ -import { getLayout } from '@components/Layouts/Layout'; -import PostHeader from '@components/PostHeader/PostHeader'; -import Sidebar from '@components/Sidebar/Sidebar'; -import { CVPreview, SocialMedia, ToC } from '@components/Widgets'; -import CVContent, { intro, meta, pdf, image } from '@content/pages/cv.mdx'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { ArticleMeta } from '@ts/types/articles'; -import { settings } from '@utils/config'; +import Heading from '@components/atoms/headings/heading'; +import Link from '@components/atoms/links/link'; +import List from '@components/atoms/lists/list'; +import ImageWidget from '@components/organisms/widgets/image-widget'; +import SocialMedia from '@components/organisms/widgets/social-media'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; +import CVContent, { data, meta } from '@content/pages/cv.mdx'; +import styles from '@styles/pages/cv.module.scss'; +import { type NextPageWithLayout } from '@ts/types/app'; import { loadTranslation } from '@utils/helpers/i18n'; -import { GetStaticProps, GetStaticPropsContext } from 'next'; +import { + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useSettings from '@utils/hooks/use-settings'; +import { NestedMDXComponents } from 'mdx/types'; +import { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import React, { ReactNode } from 'react'; import { useIntl } from 'react-intl'; -import { AboutPage, Graph, WebPage } from 'schema-dts'; -const CV: NextPageWithLayout = () => { +/** + * CV page. + */ +const CVPage: NextPageWithLayout = () => { const intl = useIntl(); - const router = useRouter(); - const dates = { - publication: meta.publishedOn, - update: meta.updatedOn, - }; + const { file, image } = data; + const { dates, intro, seo, title } = meta; + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/cv`, + }); - const pageMeta: ArticleMeta = { - dates, + const imageWidgetTitle = intl.formatMessage({ + defaultMessage: 'Others formats', + description: 'CVPage: cv preview widget title', + id: 'B9OCyV', + }); + const socialMediaTitle = intl.formatMessage({ + defaultMessage: 'Open-source projects', + description: 'CVPage: social media widget title', + id: '+Dre5J', + }); + + const headerMeta: PageLayoutProps['headerMeta'] = { + publication: { + date: dates.publication, + }, + update: dates.update + ? { + date: dates.update, + } + : undefined, }; - const pageUrl = `${settings.url}${router.asPath}`; - const pageTitle = intl.formatMessage( + + const { website } = useSettings(); + const cvAlt = intl.formatMessage( { - defaultMessage: 'CV Front-end developer - {websiteName}', - description: 'CVPage: SEO - Page title', - id: 'Y1ZdJ6', + defaultMessage: '{name} CV', + description: 'CVPage: CV image alternative text', + id: 'KUowUk', }, - { websiteName: settings.name } + { name: website.name } ); - const pageDescription = intl.formatMessage( + const cvCaption = intl.formatMessage( { - defaultMessage: - 'Discover the curriculum of {websiteName}, front-end developer located in France: skills, experiences and training.', - description: 'CVPage: SEO - Meta description', - id: 'bBdMGm', + defaultMessage: '<link>Download the CV in PDF</link>', + id: 'fN04AJ', + description: 'CVPage: download CV in PDF text', }, - { websiteName: settings.name } + { + link: (chunks: ReactNode) => ( + <Link download={true} href={file}> + {chunks} + </Link> + ), + } ); - const webpageSchema: WebPage = { - '@id': `${pageUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: pageTitle, - description: pageDescription, - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${pageUrl}`, - isPartOf: { - '@id': `${settings.url}`, - }, - }; + const widgets = [ + <ImageWidget + key="image-widget" + expanded={true} + title={imageWidgetTitle} + level={2} + image={{ alt: cvAlt, ...image }} + description={cvCaption} + imageClassName={styles.image} + />, + <SocialMedia + key="social-media" + title={socialMediaTitle} + level={2} + media={[ + { name: 'Github', url: 'https://github.com/ArmandPhilippot' }, + { name: 'Gitlab', url: 'https://gitlab.com/ArmandPhilippot' }, + { + name: 'LinkedIn', + url: 'https://www.linkedin.com/in/armandphilippot', + }, + ]} + />, + ]; - const publicationDate = new Date(dates.publication); - const updateDate = new Date(dates.update); - - const cvSchema: AboutPage = { - '@id': `${settings.url}/#cv`, - '@type': 'AboutPage', - name: pageTitle, + const { asPath } = useRouter(); + const webpageSchema = getWebPageSchema({ + description: seo.description, + locale: website.locales.default, + slug: asPath, + title: seo.title, + updateDate: dates.update, + }); + const cvSchema = getSinglePageSchema({ + cover: image.src, + dates, description: intro, - author: { '@id': `${settings.url}/#branding` }, - creator: { '@id': `${settings.url}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate.toISOString(), - datePublished: publicationDate.toISOString(), - editor: { '@id': `${settings.url}/#branding` }, - image, - inLanguage: settings.locales.defaultLocale, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - thumbnailUrl: image, - mainEntityOfPage: { '@id': `${pageUrl}` }, - }; + id: 'cv', + kind: 'about', + locale: website.locales.default, + slug: asPath, + title: title, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, cvSchema]); - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema, cvSchema], + const components: NestedMDXComponents = { + a: (props) => <Link external={true} {...props} />, + h1: (props) => <Heading level={1} {...props} />, + h2: (props) => <Heading level={2} {...props} />, + h3: (props) => <Heading level={3} {...props} />, + h4: (props) => <Heading level={4} {...props} />, + h5: (props) => <Heading level={5} {...props} />, + h6: (props) => <Heading level={6} {...props} />, + Link: (props) => <Link {...props} />, + List: (props) => <List {...props} />, }; - const title = intl.formatMessage( - { - defaultMessage: "{name}'s CV", - description: 'CVPage: page title', - id: 'Mj2BQf', - }, - { name: settings.name } - ); - return ( - <> + <PageLayout + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + headerMeta={headerMeta} + intro={intro} + title={title} + widgets={widgets} + withToC={true} + > <Head> - <title>{pageTitle}</title> - <meta name="description" content={pageDescription} /> - <meta property="og:url" content={`${pageUrl}`} /> + <title>{`${seo.title} - ${website.name}`}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> <meta property="og:type" content="article" /> <meta property="og:title" content={title} /> <meta property="og:description" content={intro} /> - <meta property="og:image" content={image} /> + <meta property="og:image" content={image.src} /> <meta property="og:image:alt" content={title} /> </Head> <Script @@ -110,72 +166,22 @@ const CV: NextPageWithLayout = () => { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <article - id="cv" - className={`${styles.article} ${styles['article--no-comments']}`} - > - <PostHeader intro={intro} meta={pageMeta} title={meta.title} /> - <Sidebar - position="left" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'CVPage: ToC sidebar aria-label', - id: 'g4DckL', - })} - > - <ToC /> - </Sidebar> - <div className={styles.body}> - <CVContent /> - </div> - <Sidebar - position="right" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Sidebar', - description: 'CVPage: right sidebar aria-label', - id: 'QHOm5t', - })} - > - <CVPreview - title={intl.formatMessage({ - defaultMessage: 'Others formats', - description: 'CVPage: cv preview widget title', - id: 'B9OCyV', - })} - imgSrc={image} - pdf={pdf} - /> - <SocialMedia - title={intl.formatMessage({ - defaultMessage: 'Open-source projects', - description: 'CVPage: social media widget title', - id: '+Dre5J', - })} - github={true} - gitlab={true} - /> - </Sidebar> - </article> - </> + <CVContent components={components} /> + </PageLayout> ); }; -CV.getLayout = getLayout; +CVPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const breadcrumbTitle = meta.title; - const { locale } = context; +export const getStaticProps: GetStaticProps = async ({ locale }) => { const translation = await loadTranslation(locale); return { props: { - breadcrumbTitle, - locale, translation, }, }; }; -export default CV; +export default CVPage; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ca0a809..6e9c4c6 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,39 +1,59 @@ import FeedIcon from '@assets/images/icon-feed.svg'; -import { ButtonLink } from '@components/Buttons'; -import { ContactIcon } from '@components/Icons'; -import Layout from '@components/Layouts/Layout'; -import { ResponsiveImage } from '@components/MDX'; -import { RecentPosts } from '@components/Widgets'; +import ButtonLink from '@components/atoms/buttons/button-link'; +import Envelop from '@components/atoms/icons/envelop'; +import Column, { type ColumnProps } from '@components/atoms/layout/column'; +import Section, { type SectionProps } from '@components/atoms/layout/section'; +import List, { type ListItem } from '@components/atoms/lists/list'; +import ResponsiveImage, { + type ResponsiveImageProps, +} from '@components/molecules/images/responsive-image'; +import Columns, { + type ColumnsProps, +} from '@components/molecules/layout/columns'; +import CardsList, { + type CardsListItem, +} from '@components/organisms/layout/cards-list'; +import { getLayout } from '@components/templates/layout/layout'; import HomePageContent from '@content/pages/homepage.mdx'; -import { getPublishedPosts } from '@services/graphql/queries'; -import styles from '@styles/pages/Home.module.scss'; -import { NextPageWithLayout, ResponsiveImageProps } from '@ts/types/app'; -import { PostsList } from '@ts/types/blog'; -import { settings } from '@utils/config'; -import { loadTranslation } from '@utils/helpers/i18n'; +import { getArticlesCard } from '@services/graphql/articles'; +import styles from '@styles/pages/home.module.scss'; +import { type ArticleCard, type NextPageWithLayout } from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { getSchemaJson, getWebPageSchema } from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useSettings from '@utils/hooks/use-settings'; import { NestedMDXComponents } from 'mdx/types'; -import { GetStaticProps, GetStaticPropsContext } from 'next'; +import { GetStaticProps } from 'next'; import Head from 'next/head'; import Script from 'next/script'; -import type { ReactElement } from 'react'; +import { ReactElement } from 'react'; import { useIntl } from 'react-intl'; -import { Graph, WebPage } from 'schema-dts'; -type HomePageProps = { - recentPosts: PostsList; +type HomeProps = { + recentPosts: ArticleCard[]; + translation?: Messages; }; -const Home: NextPageWithLayout<HomePageProps> = ({ - recentPosts, -}: { - recentPosts: PostsList; -}) => { +/** + * Home page. + */ +const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { const intl = useIntl(); + const { schema: breadcrumbSchema } = useBreadcrumb({ + title: '', + url: `/`, + }); - const CodingLinks = () => { - return ( - <ul className={styles['links-list']}> - <li> + /** + * Retrieve a list of coding links. + * + * @returns {JSX.Element} - A list of links. + */ + const CodingLinks = (): JSX.Element => { + const links: ListItem[] = [ + { + id: 'web-development', + value: ( <ButtonLink target="/thematique/developpement-web"> {intl.formatMessage({ defaultMessage: 'Web development', @@ -41,8 +61,11 @@ const Home: NextPageWithLayout<HomePageProps> = ({ id: 'vkF/RP', })} </ButtonLink> - </li> - <li> + ), + }, + { + id: 'projects', + value: ( <ButtonLink target="/projets"> {intl.formatMessage({ defaultMessage: 'Projects', @@ -50,38 +73,65 @@ const Home: NextPageWithLayout<HomePageProps> = ({ id: 'N44SOc', })} </ButtonLink> - </li> - </ul> - ); + ), + }, + ]; + + return <List kind="flex" items={links} className={styles.list} />; }; - const ColdarkRepos = () => { - return ( - <ul className={styles['links-list']}> - <li> + /** + * Retrieve a list of Coldark repositories. + * + * @returns {JSX.Element} - A list of links. + */ + const ColdarkRepos = (): JSX.Element => { + const links: ListItem[] = [ + { + id: 'coldark-github', + value: ( <ButtonLink target="https://github.com/ArmandPhilippot/coldark" - isExternal={true} + external={true} > - Github + {intl.formatMessage({ + defaultMessage: 'Github', + description: 'HomePage: Github link', + id: '3f3PzH', + })} </ButtonLink> - </li> - <li> + ), + }, + { + id: 'coldark-gitlab', + value: ( <ButtonLink target="https://gitlab.com/ArmandPhilippot/coldark" - isExternal={true} + external={true} > - Gitlab + {intl.formatMessage({ + defaultMessage: 'Gitlab', + description: 'HomePage: Gitlab link', + id: '7AnwZ7', + })} </ButtonLink> - </li> - </ul> - ); + ), + }, + ]; + + return <List kind="flex" items={links} className={styles.list} />; }; - const LibreLinks = () => { - return ( - <ul className={styles['links-list']}> - <li> + /** + * Retrieve a list of links related to Free thematic. + * + * @returns {JSX.Element} - A list of links. + */ + const LibreLinks = (): JSX.Element => { + const links: ListItem[] = [ + { + id: 'free', + value: ( <ButtonLink target="/thematique/libre"> {intl.formatMessage({ defaultMessage: 'Free', @@ -89,8 +139,11 @@ const Home: NextPageWithLayout<HomePageProps> = ({ id: 'w8GrOf', })} </ButtonLink> - </li> - <li> + ), + }, + { + id: 'linux', + value: ( <ButtonLink target="/thematique/linux"> {intl.formatMessage({ defaultMessage: 'Linux', @@ -98,15 +151,23 @@ const Home: NextPageWithLayout<HomePageProps> = ({ id: 'jASD7k', })} </ButtonLink> - </li> - </ul> - ); + ), + }, + ]; + + return <List kind="flex" items={links} className={styles.list} />; }; - const ShaarliLink = () => { - return ( - <ul className={styles['links-list']}> - <li> + /** + * Retrieve the Shaarli link. + * + * @returns {JSX.Element} - A list of links + */ + const ShaarliLink = (): JSX.Element => { + const links: ListItem[] = [ + { + id: 'shaarli', + value: ( <ButtonLink target="https://shaarli.armandphilippot.com/"> {intl.formatMessage({ defaultMessage: 'Shaarli', @@ -114,59 +175,127 @@ const Home: NextPageWithLayout<HomePageProps> = ({ id: 'i5L19t', })} </ButtonLink> - </li> - </ul> - ); + ), + }, + ]; + + return <List kind="flex" items={links} className={styles.list} />; }; - const MoreLinks = () => { - return ( - <ul className={styles['links-list']}> - <li> + /** + * Retrieve the additional links. + * + * @returns {JSX.Element} - A list of links. + */ + const MoreLinks = (): JSX.Element => { + const links: ListItem[] = [ + { + id: 'contact-me', + value: ( <ButtonLink target="/contact"> - <ContactIcon /> + <Envelop className={styles.icon} /> {intl.formatMessage({ defaultMessage: 'Contact me', description: 'HomePage: contact button text', id: 'sO/Iwj', })} </ButtonLink> - </li> - <li> + ), + }, + { + id: 'rss-feed', + value: ( <ButtonLink target="/feed"> - <FeedIcon className={styles['icon--feed']} /> + <FeedIcon className={`${styles.icon} ${styles['icon--feed']}`} /> {intl.formatMessage({ defaultMessage: 'Subscribe', description: 'HomePage: RSS feed subscription text', id: 'T4YA64', })} </ButtonLink> - </li> - </ul> + ), + }, + ]; + + return <List kind="flex" items={links} className={styles.list} />; + }; + + /** + * Get a cards list of recent posts. + * + * @returns {JSX.Element} - The cards list. + */ + const getRecentPosts = (): JSX.Element => { + const posts: CardsListItem[] = recentPosts.map((post) => { + return { + cover: post.cover, + id: post.slug, + meta: { publication: { date: post.dates.publication } }, + title: post.title, + url: `/article/${post.slug}`, + }; + }); + + return ( + <CardsList + items={posts} + titleLevel={3} + className={`${styles.list} ${styles['list--cards']}`} + /> ); }; - const getRecentPosts = () => { - return <RecentPosts posts={recentPosts} />; + /** + * Create the page sections. + * + * @param {object} obj - An object containing the section body. + * @param {ReactElement[]} obj.children - The section body. + * @returns {JSX.Element} A section element. + */ + const getSection = ({ + children, + variant, + }: { + children: ReactElement[]; + variant: SectionProps['variant']; + }): JSX.Element => { + const [headingEl, ...content] = children; + const title = headingEl.props.children; + + return ( + <Section + title={title} + content={content} + variant={variant} + className={styles.section} + /> + ); }; const components: NestedMDXComponents = { CodingLinks: CodingLinks, ColdarkRepos: ColdarkRepos, - Image: (props: ResponsiveImageProps) => ResponsiveImage({ ...props }), + Column: (props: ColumnProps) => <Column {...props} />, + Columns: (props: ColumnsProps) => ( + <Columns className={styles.columns} {...props} /> + ), + Image: (props: ResponsiveImageProps) => <ResponsiveImage {...props} />, LibreLinks: LibreLinks, MoreLinks: MoreLinks, RecentPosts: getRecentPosts, + Section: getSection, ShaarliLink: ShaarliLink, }; + const { website } = useSettings(); + const pageTitle = intl.formatMessage( { defaultMessage: '{websiteName} | Front-end developer: WordPress/React', description: 'HomePage: SEO - Page title', id: 'PXp2hv', }, - { websiteName: settings.name } + { websiteName: website.name } ); const pageDescription = intl.formatMessage( { @@ -175,35 +304,22 @@ const Home: NextPageWithLayout<HomePageProps> = ({ description: 'HomePage: SEO - Meta description', id: 'tMuNTy', }, - { websiteName: settings.name } + { websiteName: website.name } ); - - const webpageSchema: WebPage = { - '@id': `${settings.url}/#home`, - '@type': 'WebPage', - name: pageTitle, + const webpageSchema = getWebPageSchema({ description: pageDescription, - 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', - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${settings.url}`, - }; - - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema], - }; + locale: website.locales.default, + slug: '', + title: pageTitle, + }); + const schemaJsonLd = getSchemaJson([webpageSchema]); return ( <> <Head> <title>{pageTitle}</title> <meta name="description" content={pageDescription} /> - <meta property="og:type" content="website" /> - <meta property="og:url" content={`${settings.url}`} /> + <meta property="og:url" content={website.url} /> <meta property="og:title" content={pageTitle} /> <meta property="og:description" content={pageDescription} /> </Head> @@ -212,23 +328,22 @@ const Home: NextPageWithLayout<HomePageProps> = ({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <div id="home"> - <HomePageContent components={components} /> - </div> + <Script + id="schema-breadcrumb" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} + /> + <HomePageContent components={components} /> </> ); }; -Home.getLayout = function getLayout(page: ReactElement) { - return <Layout isHome={true}>{page}</Layout>; -}; +HomePage.getLayout = (page) => + getLayout(page, { isHome: true, withExtraPadding: false }); -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const { locale } = context; +export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => { const translation = await loadTranslation(locale); - const recentPosts = await getPublishedPosts({ first: 3 }); + const recentPosts = await getArticlesCard({ first: 3 }); return { props: { @@ -238,4 +353,4 @@ export const getStaticProps: GetStaticProps = async ( }; }; -export default Home; +export default HomePage; diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx index b103b5e..a58a850 100644 --- a/src/pages/mentions-legales.tsx +++ b/src/pages/mentions-legales.tsx @@ -1,111 +1,86 @@ -import { getLayout } from '@components/Layouts/Layout'; -import { Link } from '@components/MDX'; -import PostHeader from '@components/PostHeader/PostHeader'; -import Sidebar from '@components/Sidebar/Sidebar'; -import { ToC } from '@components/Widgets'; -import LegalNoticeContent, { - intro, - meta, -} from '@content/pages/legal-notice.mdx'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { ArticleMeta } from '@ts/types/articles'; -import { settings } from '@utils/config'; +import Link from '@components/atoms/links/link'; +import ResponsiveImage from '@components/molecules/images/responsive-image'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; +import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx'; +import { type NextPageWithLayout } from '@ts/types/app'; import { loadTranslation } from '@utils/helpers/i18n'; +import { + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useSettings from '@utils/hooks/use-settings'; import { NestedMDXComponents } from 'mdx/types'; -import { GetStaticProps, GetStaticPropsContext } from 'next'; +import { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; -import { useIntl } from 'react-intl'; -import { Article, Graph, WebPage } from 'schema-dts'; -const LegalNotice: NextPageWithLayout = () => { - const intl = useIntl(); - const router = useRouter(); - const dates = { - publication: meta.publishedOn, - update: meta.updatedOn, - }; - - const pageMeta: ArticleMeta = { - dates, - }; - const pageTitle = intl.formatMessage( - { - defaultMessage: 'Legal notice - {websiteName}', - description: 'LegalNoticePage: SEO - Page title', - id: '4zAUSu', - }, - { websiteName: settings.name } - ); - const pageDescription = intl.formatMessage( - { - defaultMessage: "Discover the legal notice of {websiteName}'s website.", - description: 'LegalNoticePage: SEO - Meta description', - id: 'uvB+32', - }, - { websiteName: settings.name } - ); - const pageUrl = `${settings.url}${router.asPath}`; - const title = intl.formatMessage({ - defaultMessage: 'Legal notice', - description: 'LegalNoticePage: page title', - id: '/IirIt', +/** + * Legal Notice page. + */ +const LegalNoticePage: NextPageWithLayout = () => { + const { dates, intro, seo, title } = meta; + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/mentions-legales`, }); - const publicationDate = new Date(dates.publication); - const updateDate = new Date(dates.update); - const webpageSchema: WebPage = { - '@id': `${pageUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: pageTitle, - description: pageDescription, - inLanguage: settings.locales.defaultLocale, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${pageUrl}`, - isPartOf: { - '@id': `${settings.url}`, + const headerMeta: PageLayoutProps['headerMeta'] = { + publication: { + date: dates.publication, }, - }; - - const articleSchema: Article = { - '@id': `${settings.url}/#legal-notice`, - '@type': 'Article', - name: title, - description: intro, - author: { '@id': `${settings.url}/#branding` }, - copyrightYear: publicationDate.getFullYear(), - creator: { '@id': `${settings.url}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate.toISOString(), - datePublished: publicationDate.toISOString(), - editor: { '@id': `${settings.url}/#branding` }, - headline: title, - 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, articleSchema], + update: dates.update + ? { + date: dates.update, + } + : undefined, }; const components: NestedMDXComponents = { - Link: (props) => Link(props), + Image: (props) => <ResponsiveImage {...props} />, + Link: (props) => <Link {...props} />, }; + const { website } = useSettings(); + const { asPath } = useRouter(); + const webpageSchema = getWebPageSchema({ + description: seo.description, + locale: website.locales.default, + slug: asPath, + title: seo.title, + updateDate: dates.update, + }); + const articleSchema = getSinglePageSchema({ + dates, + description: intro, + id: 'legal-notice', + kind: 'page', + locale: website.locales.default, + slug: asPath, + title, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); + return ( - <> + <PageLayout + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + headerMeta={headerMeta} + intro={intro} + title={title} + withToC={true} + > <Head> - <title>{pageTitle}</title> - <meta name="description" content={pageDescription} /> - <meta property="og:url" content={`${pageUrl}`} /> + <title>{`${seo.title} - ${website.name}`}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> <meta property="og:type" content="article" /> - <meta property="og:title" content={pageTitle} /> + <meta property="og:title" content={`${seo.title} - ${website.name}`} /> <meta property="og:description" content={intro} /> </Head> <Script @@ -113,38 +88,22 @@ const LegalNotice: NextPageWithLayout = () => { type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <article - id="legal-notice" - className={`${styles.article} ${styles['article--no-comments']}`} - > - <PostHeader intro={intro} meta={pageMeta} title={meta.title} /> - <Sidebar position="left"> - <ToC /> - </Sidebar> - <div className={styles.body}> - <LegalNoticeContent components={components} /> - </div> - </article> - </> + <LegalNoticeContent components={components} /> + </PageLayout> ); }; -LegalNotice.getLayout = getLayout; +LegalNoticePage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const breadcrumbTitle = meta.title; - const { locale } = context; +export const getStaticProps: GetStaticProps = async ({ locale }) => { const translation = await loadTranslation(locale); return { props: { - breadcrumbTitle, - locale, translation, }, }; }; -export default LegalNotice; +export default LegalNoticePage; diff --git a/src/pages/projet/[slug].tsx b/src/pages/projet/[slug].tsx deleted file mode 100644 index 1f09fed..0000000 --- a/src/pages/projet/[slug].tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { getLayout } from '@components/Layouts/Layout'; -import { CodeBlock, Gallery, Link, ResponsiveImage } from '@components/MDX'; -import PostHeader from '@components/PostHeader/PostHeader'; -import ProjectSummary from '@components/ProjectSummary/ProjectSummary'; -import Sidebar from '@components/Sidebar/Sidebar'; -import { Sharing, ToC } from '@components/Widgets'; -import styles from '@styles/pages/Page.module.scss'; -import { - NextPageWithLayout, - Project as ProjectData, - ProjectProps, -} from '@ts/types/app'; -import { settings } from '@utils/config'; -import { loadTranslation } from '@utils/helpers/i18n'; -import { - getAllProjectsFilename, - getProjectData, -} from '@utils/helpers/projects'; -import { MDXComponents, NestedMDXComponents } from 'mdx/types'; -import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; -import Head from 'next/head'; -import { useRouter } from 'next/router'; -import Script from 'next/script'; -import { ParsedUrlQuery } from 'querystring'; -import { ComponentType } from 'react'; -import { useIntl } from 'react-intl'; -import { Article, Graph, WebPage } from 'schema-dts'; - -const Project: NextPageWithLayout<ProjectProps> = ({ - project, -}: { - project: ProjectData; -}) => { - const intl = useIntl(); - const router = useRouter(); - const projectUrl = `${settings.url}${router.asPath}`; - const { id, intro, meta, title, seo } = project; - const dates = { - publication: meta.publishedOn, - update: meta.updatedOn, - }; - - const components: NestedMDXComponents = { - CodeBlock: (props) => CodeBlock(props), - Gallery: (props) => Gallery(props), - Image: (props) => ResponsiveImage({ caption: props.caption, ...props }), - Link: (props) => Link(props), - pre: ({ children }) => CodeBlock(children.props), - }; - - const ProjectContent: ComponentType<MDXComponents> = - require(`../../content/projects/${id}.mdx`).default; - - const webpageSchema: WebPage = { - '@id': `${projectUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: seo.title, - description: seo.description, - inLanguage: settings.locales.defaultLocale, - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${settings.url}`, - isPartOf: { - '@id': `${settings.url}`, - }, - }; - - const publicationDate = new Date(dates.publication); - const updateDate = new Date(dates.update); - - const articleSchema: Article = { - '@id': `${settings.url}/project`, - '@type': 'Article', - name: title, - description: intro, - author: { '@id': `${settings.url}/#branding` }, - copyrightYear: publicationDate.getFullYear(), - creator: { '@id': `${settings.url}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate.toISOString(), - datePublished: publicationDate.toISOString(), - editor: { '@id': `${settings.url}/#branding` }, - headline: title, - thumbnailUrl: meta.hasCover ? `/projects/${id}.jpg` : '', - image: meta.hasCover ? `/projects/${id}.jpg` : '', - inLanguage: settings.locales.defaultLocale, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - mainEntityOfPage: { '@id': `${projectUrl}` }, - }; - - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema, articleSchema], - }; - - return ( - <> - <Head> - <title>{seo.title}</title> - <meta name="description" content={seo.description} /> - <meta property="og:url" content={`${projectUrl}`} /> - <meta property="og:type" content="article" /> - <meta property="og:title" content={title} /> - <meta property="og:description" content={intro} /> - </Head> - <Script - id="schema-project" - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> - <article - id="project" - className={`${styles.article} ${styles['article--no-comments']}`} - > - <PostHeader title={title} intro={intro} meta={{ dates }} /> - <Sidebar - position="left" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'ProjectPage: ToC sidebar aria-label', - id: '6dXfvr', - })} - > - <ToC /> - </Sidebar> - <div className={styles.body}> - <ProjectSummary id={id} title={title} meta={meta} /> - <ProjectContent components={components} /> - </div> - <Sidebar - position="right" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Sidebar', - description: 'ProjectPage: right sidebar aria-label', - id: 'hHrNd0', - })} - > - <Sharing title={title} excerpt={intro} /> - </Sidebar> - </article> - </> - ); -}; - -Project.getLayout = getLayout; - -interface ProjectParams extends ParsedUrlQuery { - slug: string; -} - -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const { locale } = context; - const translation = await loadTranslation(locale); - const { slug } = context.params as ProjectParams; - const project = await getProjectData(slug); - const breadcrumbTitle = project.title; - - return { - props: { - breadcrumbTitle, - locale, - project, - translation, - }, - }; -}; - -export const getStaticPaths: GetStaticPaths = async () => { - const filenames = getAllProjectsFilename(); - const paths = filenames.map((filename) => { - return { - params: { - slug: filename, - }, - }; - }); - - return { - paths, - fallback: false, - }; -}; - -export default Project; diff --git a/src/pages/projets.tsx b/src/pages/projets.tsx deleted file mode 100644 index 8a81f39..0000000 --- a/src/pages/projets.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { getLayout } from '@components/Layouts/Layout'; -import PostHeader from '@components/PostHeader/PostHeader'; -import ProjectsList from '@components/ProjectsList/ProjectsList'; -import PageContent, { meta } from '@content/pages/projects.mdx'; -import styles from '@styles/pages/Projects.module.scss'; -import { Project } from '@ts/types/app'; -import { settings } from '@utils/config'; -import { loadTranslation } from '@utils/helpers/i18n'; -import { getSortedProjects } from '@utils/helpers/projects'; -import { 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 { Article, Graph, WebPage } from 'schema-dts'; - -const Projects = ({ projects }: { projects: Project[] }) => { - const intl = useIntl(); - const dates = { - publication: meta.publishedOn, - update: meta.updatedOn, - }; - const publicationDate = new Date(dates.publication); - const updateDate = new Date(dates.update); - const router = useRouter(); - const pageUrl = `${settings.url}${router.asPath}`; - const pageTitle = intl.formatMessage( - { - defaultMessage: 'Projects: open-source makings - {websiteName}', - description: 'ProjectsPage: SEO - Page title', - id: 'SX1z3t', - }, - { websiteName: settings.name } - ); - const pageDescription = intl.formatMessage( - { - defaultMessage: - 'Discover {websiteName} projects. Mostly related to web development and open source.', - description: 'ProjectsPage: SEO - Meta description', - id: 's6U1Xt', - }, - { websiteName: settings.name } - ); - - const webpageSchema: WebPage = { - '@id': `${pageUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: pageTitle, - description: pageDescription, - inLanguage: settings.locales.defaultLocale, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${pageUrl}`, - isPartOf: { - '@id': `${settings.url}`, - }, - }; - - const articleSchema: Article = { - '@id': `${settings.url}/#projects`, - '@type': 'Article', - name: meta.title, - description: pageDescription, - author: { '@id': `${settings.url}/#branding` }, - copyrightYear: publicationDate.getFullYear(), - creator: { '@id': `${settings.url}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate.toISOString(), - datePublished: publicationDate.toISOString(), - editor: { '@id': `${settings.url}/#branding` }, - headline: meta.title, - 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, articleSchema], - }; - - return ( - <> - <Head> - <title>{pageTitle}</title> - <meta name="description" content={pageDescription} /> - <meta property="og:url" content={`${pageUrl}`} /> - <meta property="og:type" content="article" /> - <meta property="og:title" content={meta.title} /> - <meta property="og:description" content={pageDescription} /> - </Head> - <Script - id="schema-projects" - type="application/ld+json" - dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} - /> - <article id="projects" className={styles.article}> - <PostHeader title={meta.title} intro={<PageContent />} /> - <div className={styles.body}> - {projects.length > 0 && <ProjectsList projects={projects} />} - </div> - </article> - </> - ); -}; - -Projects.getLayout = getLayout; - -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const breadcrumbTitle = meta.title; - const { locale } = context; - const projects: Project[] = await getSortedProjects(); - const translation = await loadTranslation(locale); - - return { - props: { - breadcrumbTitle, - locale, - projects, - translation, - }, - }; -}; - -export default Projects; diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx new file mode 100644 index 0000000..247f350 --- /dev/null +++ b/src/pages/projets/[slug].tsx @@ -0,0 +1,241 @@ +import Link from '@components/atoms/links/link'; +import SocialLink, { + type SocialWebsite, +} from '@components/atoms/links/social-link'; +import Spinner from '@components/atoms/loaders/spinner'; +import ResponsiveImage from '@components/molecules/images/responsive-image'; +import Code from '@components/molecules/layout/code'; +import Gallery from '@components/organisms/images/gallery'; +import Overview, { + type OverviewMeta, +} from '@components/organisms/layout/overview'; +import Sharing from '@components/organisms/widgets/sharing'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; +import styles from '@styles/pages/project.module.scss'; +import { + type NextPageWithLayout, + type ProjectPreview, + type Repos, +} from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { getProjectData, getProjectFilenames } from '@utils/helpers/projects'; +import { + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import { capitalize } from '@utils/helpers/strings'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useGithubApi, { type RepoData } from '@utils/hooks/use-github-api'; +import useSettings from '@utils/hooks/use-settings'; +import { MDXComponents, NestedMDXComponents } from 'mdx/types'; +import { GetStaticPaths, GetStaticProps } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; +import { ComponentType } from 'react'; +import { useIntl } from 'react-intl'; + +type ProjectPageProps = { + project: ProjectPreview; + translation: Messages; +}; + +/** + * Project page. + */ +const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { + const { id, intro, meta, title } = project; + const { cover, dates, license, repos, seo, technologies } = meta; + const intl = useIntl(); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/projets/${id}`, + }); + + const ProjectContent: ComponentType<MDXComponents> = + require(`../../content/projects/${id}.mdx`).default; + + const components: NestedMDXComponents = { + Code: (props) => <Code {...props} />, + Gallery: (props) => <Gallery {...props} />, + Image: (props) => <ResponsiveImage withBorders={true} {...props} />, + Link: (props) => <Link {...props} />, + pre: ({ children }) => <Code {...children.props} />, + }; + + const { website } = useSettings(); + const { asPath } = useRouter(); + const pageUrl = `${website.url}${asPath}`; + + const headerMeta: PageLayoutProps['headerMeta'] = { + publication: { date: dates.publication }, + update: + dates.update && dates.update !== dates.publication + ? { date: dates.update } + : undefined, + }; + + /** + * Retrieve the repositories links. + * + * @param {Repos} repos - A repositories object. + * @returns {JSX.Element[]} - An array of SocialLink. + */ + const getReposLinks = (repositories: Repos): JSX.Element[] => { + const links = []; + + for (const [name, url] of Object.entries(repositories)) { + const socialWebsite = capitalize(name) as SocialWebsite; + const socialUrl = `https://${name}.com/${url}`; + + links.push(<SocialLink name={socialWebsite} url={socialUrl} />); + } + + return links; + }; + + const { isError, isLoading, data } = useGithubApi(meta.repos!.github!); + + const getGithubData = (key: keyof RepoData) => { + if (isError) return 'Error'; + if (isLoading || !data) return <Spinner />; + + switch (key) { + case 'created_at': + return data.created_at; + case 'updated_at': + return data.updated_at; + case 'stargazers_count': + const stars = intl.formatMessage( + { + defaultMessage: + '{starsCount, plural, =0 {No stars on Github} one {# star on Github} other {# stars on Github}}', + id: 'Gnf1Si', + description: 'Projets: Github stars count', + }, + { starsCount: data.stargazers_count } + ); + return ( + <> + ⭐ + <Link href={`https://github.com/${repos!.github}/stargazers`}> + {stars} + </Link> + </> + ); + } + }; + + const overviewData: OverviewMeta = { + creation: data && { date: getGithubData('created_at') as string }, + update: data && { date: getGithubData('updated_at') as string }, + license, + popularity: data && getGithubData('stargazers_count'), + repositories: repos ? getReposLinks(repos) : undefined, + technologies, + }; + + const webpageSchema = getWebPageSchema({ + description: seo.description, + locale: website.locales.default, + slug: asPath, + title: seo.title, + updateDate: dates.update, + }); + const articleSchema = getSinglePageSchema({ + cover: `/projects/${id}.jpg`, + dates, + description: intro, + id: 'project', + kind: 'page', + locale: website.locales.default, + slug: asPath, + title, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); + + return ( + <> + <Head> + <title>{`${seo.title} - ${website.name}`}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${pageUrl}`} /> + <meta property="og:type" content="article" /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={intro} /> + </Head> + <Script + id="schema-project" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <PageLayout + title={title} + intro={intro} + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + headerMeta={headerMeta} + withToC={true} + widgets={[ + <Sharing + key="sharing-widget" + data={{ excerpt: intro, title, url: pageUrl }} + media={[ + 'diaspora', + 'email', + 'facebook', + 'journal-du-hacker', + 'linkedin', + 'twitter', + ]} + className={styles.widget} + />, + ]} + > + <Overview cover={cover} meta={overviewData} /> + <ProjectContent components={components} /> + </PageLayout> + </> + ); +}; + +ProjectPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); + +export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({ + locale, + params, +}) => { + const translation = await loadTranslation(locale); + const { slug } = params!; + const project = await getProjectData(slug as string); + + return { + props: { + project, + translation, + }, + }; +}; + +export const getStaticPaths: GetStaticPaths = async () => { + const filenames = getProjectFilenames(); + const paths = filenames.map((filename) => { + return { + params: { + slug: filename, + }, + }; + }); + + return { + paths, + fallback: false, + }; +}; + +export default ProjectPage; diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx new file mode 100644 index 0000000..dbca019 --- /dev/null +++ b/src/pages/projets/index.tsx @@ -0,0 +1,123 @@ +import Link from '@components/atoms/links/link'; +import CardsList, { + type CardsListItem, +} from '@components/organisms/layout/cards-list'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout from '@components/templates/page/page-layout'; +import PageContent, { meta } from '@content/pages/projects.mdx'; +import styles from '@styles/pages/projects.module.scss'; +import { type NextPageWithLayout, type ProjectCard } from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { getProjectsCard } from '@utils/helpers/projects'; +import { + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useSettings from '@utils/hooks/use-settings'; +import { NestedMDXComponents } from 'mdx/types'; +import { GetStaticProps } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import Script from 'next/script'; + +type ProjectsPageProps = { + projects: ProjectCard[]; + translation?: Messages; +}; + +/** + * Projects page. + */ +const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => { + const { dates, seo, title } = meta; + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/projets`, + }); + + const items: CardsListItem[] = projects.map( + ({ id, meta: projectMeta, slug, title: projectTitle }) => { + const { cover, tagline, technologies } = projectMeta; + + return { + cover, + id: id as string, + meta: { technologies: technologies }, + tagline, + title: projectTitle, + url: `/projets/${slug}`, + }; + } + ); + + const components: NestedMDXComponents = { + Links: (props) => <Link {...props} />, + }; + + const { website } = useSettings(); + const { asPath } = useRouter(); + const webpageSchema = getWebPageSchema({ + description: seo.description, + locale: website.locales.default, + slug: asPath, + title: seo.title, + updateDate: dates.update, + }); + const articleSchema = getSinglePageSchema({ + dates, + description: seo.description, + id: 'projects', + kind: 'page', + locale: website.locales.default, + slug: asPath, + title, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); + + return ( + <> + <Head> + <title>{`${seo.title} - ${website.name}`}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> + <meta property="og:type" content="article" /> + <meta property="og:title" content={`${seo.title} - ${website.name}`} /> + <meta property="og:description" content={seo.description} /> + </Head> + <Script + id="schema-projects" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <PageLayout + title={title} + intro={<PageContent components={components} />} + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + > + <CardsList items={items} titleLevel={2} className={styles.list} /> + </PageLayout> + </> + ); +}; + +ProjectsPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); + +export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({ + locale, +}) => { + const projects = await getProjectsCard(); + const translation = await loadTranslation(locale); + + return { + props: { + projects: JSON.parse(JSON.stringify(projects)), + translation, + }, + }; +}; + +export default ProjectsPage; diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index b843f8d..dbbec55 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -1,213 +1,235 @@ -import { Button } from '@components/Buttons'; -import { getLayout } from '@components/Layouts/Layout'; -import PaginationCursor from '@components/PaginationCursor/PaginationCursor'; -import PostHeader from '@components/PostHeader/PostHeader'; -import PostsList from '@components/PostsList/PostsList'; -import Sidebar from '@components/Sidebar/Sidebar'; -import Spinner from '@components/Spinner/Spinner'; -import { ThematicsList, TopicsList } from '@components/Widgets'; -import { getPublishedPosts } from '@services/graphql/queries'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { PostsList as PostsListData } from '@ts/types/blog'; -import { settings } from '@utils/config'; -import { getIntlInstance, loadTranslation } from '@utils/helpers/i18n'; -import { GetStaticProps, GetStaticPropsContext } from 'next'; +import Notice from '@components/atoms/layout/notice'; +import Spinner from '@components/atoms/loaders/spinner'; +import PostsList 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 { getArticles, getTotalArticles } from '@services/graphql/articles'; +import { + getThematicsPreview, + getTotalThematics, +} from '@services/graphql/thematics'; +import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics'; +import { type NextPageWithLayout } from '@ts/types/app'; +import { + type RawArticle, + type RawThematicPreview, + type RawTopicPreview, +} from '@ts/types/raw-data'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getLinksListItems, + getPageLinkFromRawData, + getPostsList, +} from '@utils/helpers/pages'; +import { + getBlogSchema, + getSchemaJson, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +import useDataFromAPI from '@utils/hooks/use-data-from-api'; +import usePagination from '@utils/hooks/use-pagination'; +import useSettings from '@utils/hooks/use-settings'; +import { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import { useEffect, useRef, useState } from 'react'; +import Script from 'next/script'; import { useIntl } from 'react-intl'; -import useSWRInfinite from 'swr/infinite'; -const Search: NextPageWithLayout = () => { - const intl = useIntl(); - const [query, setQuery] = useState(''); - const router = useRouter(); - const lastPostRef = useRef<HTMLSpanElement>(null); - - useEffect(() => { - if (!router.isReady) return; - - if (router.query?.s && typeof router.query.s === 'string') { - setQuery(router.query.s); - } - }, [router.isReady, router.query.s]); - - const getKey = (pageIndex: number, previousData: PostsListData) => { - if (previousData && !previousData.posts) return null; - - return pageIndex === 0 - ? { first: settings.postsPerPage, searchQuery: query } - : { - first: settings.postsPerPage, - after: previousData.pageInfo.endCursor, - searchQuery: query, - }; - }; - - const { data, error, size, setSize } = useSWRInfinite( - getKey, - getPublishedPosts - ); - const [totalPostsCount, setTotalPostsCount] = useState<number>(0); - - useEffect(() => { - if (data) setTotalPostsCount(data[0].pageInfo.total); - }, [data]); - - const [loadedPostsCount, setLoadedPostsCount] = useState<number>( - settings.postsPerPage - ); - - useEffect(() => { - if (data && data.length > 0) { - const newCount = - settings.postsPerPage + - data[0].pageInfo.total - - data[data.length - 1].pageInfo.total; - setLoadedPostsCount(newCount); - } - }, [data]); - - const isLoadingInitialData = !data && !error; - const isLoadingMore: boolean = - isLoadingInitialData || - (size > 0 && data !== undefined && typeof data[size - 1] === 'undefined'); - - const hasNextPage = data && data[data.length - 1].pageInfo.hasNextPage; +type SearchPageProps = { + thematicsList: RawThematicPreview[]; + topicsList: RawTopicPreview[]; + translation: Messages; +}; - const title = query +/** + * Search page. + */ +const SearchPage: NextPageWithLayout<SearchPageProps> = ({ + thematicsList, + topicsList, +}) => { + const intl = useIntl(); + const { asPath, query } = useRouter(); + const title = query.s ? intl.formatMessage( { defaultMessage: 'Search results for {query}', - description: 'SearchPage: search results text', - id: 'VSGuGE', + description: 'SearchPage: SEO - Page title', + id: 'ZNBhDP', }, - { query } + { query: query.s as string } ) : intl.formatMessage({ defaultMessage: 'Search', - description: 'SearchPage: page title', - id: 'U+35YD', + description: 'SearchPage: SEO - Page title', + id: 'WDwNDl', }); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/recherche`, + }); - const description = query + const { blog, website } = useSettings(); + const pageTitle = `${title} - ${website.name}`; + const pageDescription = query.s ? intl.formatMessage( { - defaultMessage: 'Discover search results for {query}', - description: 'SearchPage: meta description with query', - id: 'A4LTGq', + defaultMessage: + 'Discover search results for {query} on {websiteName}.', + description: 'SearchPage: SEO - Meta description', + id: 'pg26sn', }, - { query } + { query: query.s as string, websiteName: website.name } ) : intl.formatMessage( { - defaultMessage: 'Search for a post on {websiteName}', - description: 'SearchPage: meta description without query', - id: 'PrIz5o', + defaultMessage: 'Search for a post on {websiteName}.', + description: 'SearchPage: SEO - Meta description', + id: 'npisb3', }, - { websiteName: settings.name } + { websiteName: website.name } ); + const webpageSchema = getWebPageSchema({ + description: pageDescription, + locale: website.locales.default, + slug: asPath, + title: pageTitle, + }); + const blogSchema = getBlogSchema({ + isSinglePage: false, + locale: website.locales.default, + slug: asPath, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); + + const { + data, + error, + isLoadingInitialData, + isLoadingMore, + hasNextPage, + setSize, + } = usePagination<RawArticle>({ + fallbackData: [], + fetcher: getArticles, + perPage: blog.postsPerPage, + search: query.s as string, + }); - const head = { - title: `${title} | ${settings.name}`, - description, - }; + const totalArticles = useDataFromAPI<number>(() => + getTotalArticles(query.s as string) + ); - const loadMorePosts = () => { - if (lastPostRef.current) { - lastPostRef.current.focus(); - } - setSize(size + 1); + /** + * Load more posts handler. + */ + const loadMore = () => { + setSize((prevSize) => prevSize + 1); }; - const getPostsList = () => { - if (error) - return intl.formatMessage({ - defaultMessage: 'Failed to load.', - description: 'SearchPage: failed to load text', - id: 'fOe8rH', - }); - if (!data) return <Spinner />; + const thematicsListTitle = intl.formatMessage({ + defaultMessage: 'Thematics', + description: 'SearchPage: thematics list widget title', + id: 'Dq6+WH', + }); - return <PostsList ref={lastPostRef} data={data} showYears={false} />; - }; + const topicsListTitle = intl.formatMessage({ + defaultMessage: 'Topics', + description: 'SearchPage: topics list widget title', + id: 'N804XO', + }); return ( <> <Head> - <title>{head.title}</title> - <meta name="description" content={head.description} /> + <title>{pageTitle}</title> + <meta name="description" content={pageDescription} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> + <meta property="og:type" content="website" /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={pageDescription} /> </Head> - <article - className={`${styles.article} ${styles['article--no-comments']}`} + <Script + id="schema-blog" + type="application/ld+json" + dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} + /> + <PageLayout + title={title} + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + headerMeta={{ total: totalArticles }} + widgets={[ + <LinksListWidget + key="thematics-list" + items={getLinksListItems( + thematicsList.map((thematic) => + getPageLinkFromRawData(thematic, 'thematic') + ) + )} + title={thematicsListTitle} + level={2} + />, + <LinksListWidget + key="topics-list" + items={getLinksListItems( + topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) + )} + title={topicsListTitle} + level={2} + />, + ]} > - <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: 'SearchPage: load more text', - id: 'pEtJik', - })} - </Button> - </> - )} - </div> - <Sidebar position="right"> - <ThematicsList - title={intl.formatMessage({ - defaultMessage: 'Thematics', - description: 'SearchPage: thematics list widget title', - id: 'Dq6+WH', - })} + {data && data.length > 0 ? ( + <PostsList + baseUrl="/recherche/page/" + byYear={true} + isLoading={isLoadingMore || isLoadingInitialData} + loadMore={loadMore} + posts={getPostsList(data)} + searchPage="/recherche/" + showLoadMoreBtn={hasNextPage} + total={totalArticles || 0} /> - <TopicsList - title={intl.formatMessage({ - defaultMessage: 'Topics', - description: 'SearchPage: topics list widget title', - id: 'N804XO', + ) : ( + <Spinner /> + )} + {error && ( + <Notice + kind="error" + message={intl.formatMessage({ + defaultMessage: 'Failed to load.', + description: 'SearchPage: failed to load text', + id: 'fOe8rH', })} /> - </Sidebar> - </article> + )} + </PageLayout> </> ); }; -Search.getLayout = getLayout; +SearchPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const intl = await getIntlInstance(); - const breadcrumbTitle = intl.formatMessage({ - defaultMessage: 'Search', - description: 'SearchPage: breadcrumb item', - id: 'TfU6Qm', - }); - const { locale } = context; +export const getStaticProps: GetStaticProps<SearchPageProps> = async ({ + locale, +}) => { + 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: { - breadcrumbTitle, - locale, + thematicsList: thematics.edges.map((edge) => edge.node), + topicsList: topics.edges.map((edge) => edge.node), translation, }, }; }; -export default Search; +export default SearchPage; diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 30dd36c..48924e5 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -1,224 +1,230 @@ -import { getLayout } from '@components/Layouts/Layout'; -import PostHeader from '@components/PostHeader/PostHeader'; -import PostPreview from '@components/PostPreview/PostPreview'; -import Sidebar from '@components/Sidebar/Sidebar'; -import Spinner from '@components/Spinner/Spinner'; -import { RelatedThematics, ToC, TopicsList } from '@components/Widgets'; +import Heading from '@components/atoms/headings/heading'; +import ResponsiveImage from '@components/molecules/images/responsive-image'; +import PostsList from '@components/organisms/layout/posts-list'; +import LinksListWidget from '@components/organisms/widgets/links-list-widget'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; import { - getAllTopics, - getAllTopicsSlug, + getAllTopicsSlugs, getTopicBySlug, -} from '@services/graphql/queries'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { ArticleMeta } from '@ts/types/articles'; -import { TopicProps, ThematicPreview } from '@ts/types/taxonomies'; -import { settings } from '@utils/config'; -import { getFormattedPaths } from '@utils/helpers/format'; -import { loadTranslation } from '@utils/helpers/i18n'; -import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; + getTopicsPreview, + getTotalTopics, +} from '@services/graphql/topics'; +import styles from '@styles/pages/topic.module.scss'; +import { + type NextPageWithLayout, + type PageLink, + type Topic, +} from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getLinksListItems, + getPageLinkFromRawData, + getPostsWithUrl, +} from '@utils/helpers/pages'; +import { + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +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 { useRef } from 'react'; import { useIntl } from 'react-intl'; -import { Article as Article, Graph, WebPage } from 'schema-dts'; - -const Topic: NextPageWithLayout<TopicProps> = ({ topic, allTopics }) => { - const intl = useIntl(); - const relatedThematics = useRef<ThematicPreview[]>([]); - const router = useRouter(); - - if (router.isFallback) return <Spinner />; - - const updateRelatedThematics = (newThematics: ThematicPreview[]) => { - newThematics.forEach((thematic) => { - const thematicIndex = relatedThematics.current.findIndex( - (relatedThematic) => relatedThematic.id === thematic.id - ); - const hasThematic = thematicIndex === -1 ? false : true; - - if (!hasThematic) relatedThematics.current.push(thematic); - }); - }; - - const getPostsList = () => { - return [...topic.posts].reverse().map((post) => { - updateRelatedThematics(post.thematics); - - return ( - <li key={post.id} className={styles.item}> - <PostPreview post={post} titleLevel={3} /> - </li> - ); - }); - }; - - const meta: ArticleMeta = { - dates: topic.dates, - results: topic.posts.length, - website: topic.officialWebsite, - }; - const topicUrl = `${settings.url}${router.asPath}`; - - const webpageSchema: WebPage = { - '@id': `${topicUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: topic.seo.title, - description: topic.seo.metaDesc, - inLanguage: settings.locales.defaultLocale, - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${settings.url}`, - isPartOf: { - '@id': `${settings.url}`, - }, - }; - const publicationDate = new Date(topic.dates.publication); - const updateDate = new Date(topic.dates.update); +export type TopicPageProps = { + currentTopic: Topic; + topics: PageLink[]; + translation: Messages; +}; - const articleSchema: Article = { - '@id': `${settings.url}/#topic`, - '@type': 'Article', - name: topic.title, - description: topic.intro, - author: { '@id': `${settings.url}/#branding` }, - copyrightYear: publicationDate.getFullYear(), - creator: { '@id': `${settings.url}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate.toISOString(), - datePublished: publicationDate.toISOString(), - editor: { '@id': `${settings.url}/#branding` }, - headline: topic.title, - thumbnailUrl: topic.featuredImage?.sourceUrl, - image: topic.featuredImage?.sourceUrl, - inLanguage: settings.locales.defaultLocale, - isPartOf: { '@id': `${settings.url}/blog` }, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - mainEntityOfPage: { '@id': `${topicUrl}` }, - subjectOf: { '@id': `${settings.url}/blog` }, +const TopicPage: NextPageWithLayout<TopicPageProps> = ({ + currentTopic, + topics, +}) => { + const { content, intro, meta, slug, title } = currentTopic; + const { + articles, + cover, + dates, + seo, + thematics, + website: officialWebsite, + } = meta; + const intl = useIntl(); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/sujet/${slug}`, + }); + + const headerMeta: PageLayoutProps['headerMeta'] = { + publication: { date: dates.publication }, + update: dates.update ? { date: dates.update } : undefined, + website: officialWebsite, + total: articles ? articles.length : undefined, }; - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema, articleSchema], + const { website } = useSettings(); + const { asPath } = useRouter(); + const webpageSchema = getWebPageSchema({ + description: seo.description, + locale: website.locales.default, + slug: asPath, + title: seo.title, + updateDate: dates.update, + }); + const articleSchema = getSinglePageSchema({ + cover: cover?.src, + dates, + description: intro, + id: 'topic', + kind: 'page', + locale: website.locales.default, + slug: asPath, + title, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); + + const topicsListTitle = intl.formatMessage({ + defaultMessage: 'Other topics', + description: 'TopicPage: other topics list widget title', + id: 'JpC3JH', + }); + + const thematicsListTitle = intl.formatMessage({ + defaultMessage: 'Related thematics', + description: 'TopicPage: related thematics list widget title', + id: '/sRqPT', + }); + + const getPageHeading = () => { + return ( + <> + {cover && <ResponsiveImage className={styles.logo} {...cover} />} + {title} + </> + ); }; return ( <> <Head> - <title>{topic.seo.title}</title> - <meta name="description" content={topic.seo.metaDesc} /> - <meta property="og:url" content={`${topicUrl}`} /> + <title>{seo.title}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> <meta property="og:type" content="article" /> - <meta property="og:title" content={topic.title} /> - <meta property="og:description" content={topic.intro} /> - <meta property="og:image" content={topic.featuredImage?.sourceUrl} /> - <meta property="og:image:alt" content={topic.featuredImage?.altText} /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={intro} /> </Head> <Script - id="schema-subject" + id="schema-project" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <article - id="topic" - className={`${styles.article} ${styles['article--no-comments']}`} + <PageLayout + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + title={getPageHeading()} + intro={intro} + headerMeta={headerMeta} + widgets={ + thematics + ? [ + <LinksListWidget + key="related-thematics" + items={getLinksListItems(thematics)} + title={thematicsListTitle} + level={2} + />, + <LinksListWidget + key="topics" + items={getLinksListItems(topics)} + title={topicsListTitle} + level={2} + />, + ] + : [] + } > - <PostHeader - cover={topic.featuredImage} - intro={topic.intro} - meta={meta} - title={topic.title} - /> - <Sidebar - position="left" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'TopicPage: ToC sidebar aria-label', - id: 'lsDB5G', - })} - > - <ToC /> - </Sidebar> - <div className={styles.body}> - <div dangerouslySetInnerHTML={{ __html: topic.content }}></div> - {topic.posts.length > 0 && ( - <section className={styles.section}> - <h2> - {intl.formatMessage( - { - defaultMessage: 'All posts in {name}', - description: 'TopicPage: posts list title', - id: 'FLkF2R', - }, - { name: topic.title } - )} - </h2> - <ol className={styles.list}>{getPostsList()}</ol> - </section> - )} - </div> - <Sidebar - position="right" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Sidebar', - description: 'TopicPage: right sidebar aria-label', - id: 'eu3beS', - })} - > - <RelatedThematics thematics={relatedThematics.current} /> - <TopicsList - initialData={allTopics} - title={intl.formatMessage({ - defaultMessage: 'Others topics', - description: 'TopicPage: topics list widget title', - id: '+4tiVb', - })} - /> - </Sidebar> - </article> + {content && <div dangerouslySetInnerHTML={{ __html: content }} />} + {articles && ( + <> + <Heading level={2}> + {intl.formatMessage( + { + defaultMessage: 'All posts in {topicName}', + description: 'TopicPage: posts list heading', + id: 'zEN3fd', + }, + { topicName: title } + )} + </Heading> + <PostsList + baseUrl="/sujet/page/" + byYear={true} + posts={getPostsWithUrl(articles)} + searchPage="/recherche/" + titleLevel={3} + total={articles.length} + /> + </> + )} + </PageLayout> </> ); }; -Topic.getLayout = getLayout; +TopicPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -interface PostParams extends ParsedUrlQuery { +interface TopicParams extends ParsedUrlQuery { slug: string; } -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const { locale } = context; +export const getStaticProps: GetStaticProps<TopicPageProps> = async ({ + locale, + params, +}) => { + const currentTopic = await getTopicBySlug( + params!.slug as TopicParams['slug'] + ); + const totalTopics = await getTotalTopics(); + const allTopicsEdges = await getTopicsPreview({ + first: totalTopics, + }); + const allTopics = allTopicsEdges.edges.map((edge) => + getPageLinkFromRawData(edge.node, 'topic') + ); + const topicsLinks = allTopics.filter( + (topic) => topic.url !== `/sujet/${params!.slug as TopicParams['slug']}` + ); const translation = await loadTranslation(locale); - const { slug } = context.params as PostParams; - const topic = await getTopicBySlug(slug); - const allTopics = await getAllTopics(); - const breadcrumbTitle = topic.title; return { props: { - allTopics, - breadcrumbTitle, - locale, - topic, + currentTopic: JSON.parse(JSON.stringify(currentTopic)), + topics: JSON.parse(JSON.stringify(topicsLinks)), translation, }, }; }; export const getStaticPaths: GetStaticPaths = async () => { - const allTopics = await getAllTopicsSlug(); - const paths = getFormattedPaths(allTopics); + const slugs = await getAllTopicsSlugs(); + const paths = slugs.map((slug) => { + return { params: { slug } }; + }); return { paths, - fallback: true, + fallback: false, }; }; -export default Topic; +export default TopicPage; diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index db22214..7aa6c1c 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -1,214 +1,211 @@ -import { getLayout } from '@components/Layouts/Layout'; -import PostHeader from '@components/PostHeader/PostHeader'; -import PostPreview from '@components/PostPreview/PostPreview'; -import Sidebar from '@components/Sidebar/Sidebar'; -import Spinner from '@components/Spinner/Spinner'; -import { RelatedTopics, ThematicsList, ToC } from '@components/Widgets'; +import Heading from '@components/atoms/headings/heading'; +import PostsList from '@components/organisms/layout/posts-list'; +import LinksListWidget from '@components/organisms/widgets/links-list-widget'; +import { getLayout } from '@components/templates/layout/layout'; +import PageLayout, { + type PageLayoutProps, +} from '@components/templates/page/page-layout'; import { - getAllThematics, - getAllThematicsSlug, + getAllThematicsSlugs, getThematicBySlug, -} from '@services/graphql/queries'; -import styles from '@styles/pages/Page.module.scss'; -import { NextPageWithLayout } from '@ts/types/app'; -import { ArticleMeta } from '@ts/types/articles'; -import { TopicPreview, ThematicProps } from '@ts/types/taxonomies'; -import { settings } from '@utils/config'; -import { getFormattedPaths } from '@utils/helpers/format'; -import { loadTranslation } from '@utils/helpers/i18n'; -import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; + getThematicsPreview, + getTotalThematics, +} from '@services/graphql/thematics'; +import { + type NextPageWithLayout, + type PageLink, + type Thematic, +} from '@ts/types/app'; +import { loadTranslation, type Messages } from '@utils/helpers/i18n'; +import { + getLinksListItems, + getPageLinkFromRawData, + getPostsWithUrl, +} from '@utils/helpers/pages'; +import { + getSchemaJson, + getSinglePageSchema, + getWebPageSchema, +} from '@utils/helpers/schema-org'; +import useBreadcrumb from '@utils/hooks/use-breadcrumb'; +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 { useRef } from 'react'; import { useIntl } from 'react-intl'; -import { Article, Graph, WebPage } from 'schema-dts'; -const Thematic: NextPageWithLayout<ThematicProps> = ({ - thematic, - allThematics, +export type ThematicPageProps = { + currentThematic: Thematic; + thematics: PageLink[]; + translation: Messages; +}; + +const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ + currentThematic, + thematics, }) => { + const { content, intro, meta, slug, title } = currentThematic; + const { articles, dates, seo, topics } = meta; const intl = useIntl(); - const relatedTopics = useRef<TopicPreview[]>([]); - const router = useRouter(); - - if (router.isFallback) return <Spinner />; - - const updateRelatedTopics = (newTopics: TopicPreview[]) => { - newTopics.forEach((topic) => { - const topicIndex = relatedTopics.current.findIndex( - (relatedTopic) => relatedTopic.id === topic.id - ); - const hasTopic = topicIndex === -1 ? false : true; - - if (!hasTopic) relatedTopics.current.push(topic); - }); - }; - - const getPostsList = () => { - return [...thematic.posts].reverse().map((post) => { - updateRelatedTopics(post.topics); - - return ( - <li key={post.id} className={styles.item}> - <PostPreview post={post} titleLevel={3} /> - </li> - ); - }); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: `/thematique/${slug}`, + }); + + const headerMeta: PageLayoutProps['headerMeta'] = { + publication: { date: dates.publication }, + update: dates.update ? { date: dates.update } : undefined, + total: articles ? articles.length : undefined, }; - const meta: ArticleMeta = { - dates: thematic.dates, - results: thematic.posts.length, - }; - const thematicUrl = `${settings.url}${router.asPath}`; - - const webpageSchema: WebPage = { - '@id': `${thematicUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${settings.url}/#breadcrumb` }, - name: thematic.seo.title, - description: thematic.seo.metaDesc, - inLanguage: settings.locales.defaultLocale, - reviewedBy: { '@id': `${settings.url}/#branding` }, - url: `${settings.url}`, - }; - - const publicationDate = new Date(thematic.dates.publication); - const updateDate = new Date(thematic.dates.update); - - const articleSchema: Article = { - '@id': `${settings.url}/#thematic`, - '@type': 'Article', - name: thematic.title, - description: thematic.intro, - author: { '@id': `${settings.url}/#branding` }, - copyrightYear: publicationDate.getFullYear(), - creator: { '@id': `${settings.url}/#branding` }, - dateCreated: publicationDate.toISOString(), - dateModified: updateDate.toISOString(), - datePublished: publicationDate.toISOString(), - editor: { '@id': `${settings.url}/#branding` }, - headline: thematic.title, - inLanguage: settings.locales.defaultLocale, - isPartOf: { '@id': `${settings.url}/blog` }, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - mainEntityOfPage: { '@id': `${thematicUrl}` }, - subjectOf: { '@id': `${settings.url}/blog` }, - }; - - const schemaJsonLd: Graph = { - '@context': 'https://schema.org', - '@graph': [webpageSchema, articleSchema], - }; + const { website } = useSettings(); + const { asPath } = useRouter(); + const webpageSchema = getWebPageSchema({ + description: seo.description, + locale: website.locales.default, + slug: asPath, + title: seo.title, + updateDate: dates.update, + }); + const articleSchema = getSinglePageSchema({ + dates, + description: intro, + id: 'thematic', + kind: 'page', + locale: website.locales.default, + slug: asPath, + title, + }); + const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); + + const thematicsListTitle = intl.formatMessage({ + defaultMessage: 'Other thematics', + description: 'ThematicPage: other thematics list widget title', + id: 'KVSWGP', + }); + + const topicsListTitle = intl.formatMessage({ + defaultMessage: 'Related topics', + description: 'ThematicPage: related topics list widget title', + id: '/42Z0z', + }); return ( <> <Head> - <title>{thematic.seo.title}</title> - <meta name="description" content={thematic.seo.metaDesc} /> - <meta property="og:url" content={`${thematic}`} /> + <title>{seo.title}</title> + <meta name="description" content={seo.description} /> + <meta property="og:url" content={`${website.url}${asPath}`} /> <meta property="og:type" content="article" /> - <meta property="og:title" content={thematic.title} /> - <meta property="og:description" content={thematic.intro} /> + <meta property="og:title" content={title} /> + <meta property="og:description" content={intro} /> </Head> <Script - id="schema-thematic" + id="schema-project" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }} /> - <article - id="thematic" - className={`${styles.article} ${styles['article--no-comments']}`} + <PageLayout + breadcrumb={breadcrumbItems} + breadcrumbSchema={breadcrumbSchema} + title={title} + intro={intro} + headerMeta={headerMeta} + widgets={ + topics + ? [ + <LinksListWidget + key="thematics" + items={getLinksListItems(thematics)} + title={thematicsListTitle} + level={2} + />, + <LinksListWidget + key="related-topics" + items={getLinksListItems(topics)} + title={topicsListTitle} + level={2} + />, + ] + : [] + } > - <PostHeader intro={thematic.intro} meta={meta} title={thematic.title} /> - <Sidebar - position="left" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'ThematicPage: ToC sidebar aria-label', - id: 'YwvYfw', - })} - > - <ToC /> - </Sidebar> - <div className={styles.body}> - <div dangerouslySetInnerHTML={{ __html: thematic.content }}></div> - {thematic.posts.length > 0 && ( - <section className={styles.section}> - <h2> - {intl.formatMessage( - { - defaultMessage: 'All posts in {name}', - description: 'ThematicPage: posts list title', - id: 'P7fxX2', - }, - { name: thematic.title } - )} - </h2> - <ol className={styles.list}>{getPostsList()}</ol> - </section> - )} - </div> - <Sidebar - position="right" - ariaLabel={intl.formatMessage({ - defaultMessage: 'Sidebar', - description: 'ThematicPage: right sidebar aria-label', - id: 'syLgY9', - })} - > - <RelatedTopics topics={relatedTopics.current} /> - <ThematicsList - initialData={allThematics} - title={intl.formatMessage({ - defaultMessage: 'Others thematics', - description: 'ThematicPage: thematics list widget title', - id: 'norrGp', - })} - /> - </Sidebar> - </article> + <div dangerouslySetInnerHTML={{ __html: content }} /> + {articles && ( + <> + <Heading level={2}> + {intl.formatMessage( + { + defaultMessage: 'All posts in {thematicName}', + description: 'ThematicPage: posts list heading', + id: 'LszkU6', + }, + { thematicName: title } + )} + </Heading> + <PostsList + baseUrl="/thematique/page/" + byYear={true} + posts={getPostsWithUrl(articles)} + searchPage="/recherche/" + titleLevel={3} + total={articles.length} + /> + </> + )} + </PageLayout> </> ); }; -Thematic.getLayout = getLayout; +ThematicPage.getLayout = (page) => + getLayout(page, { useGrid: true, withExtraPadding: true }); -interface PostParams extends ParsedUrlQuery { +interface ThematicParams extends ParsedUrlQuery { slug: string; } -export const getStaticProps: GetStaticProps = async ( - context: GetStaticPropsContext -) => { - const { locale } = context; +export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({ + locale, + params, +}) => { + const currentThematic = await getThematicBySlug( + params!.slug as ThematicParams['slug'] + ); + const totalThematics = await getTotalThematics(); + const allThematicsEdges = await getThematicsPreview({ + first: totalThematics, + }); + const allThematics = allThematicsEdges.edges.map((edge) => + getPageLinkFromRawData(edge.node, 'thematic') + ); + const allThematicsLinks = allThematics.filter( + (thematic) => + thematic.url !== `/thematique/${params!.slug as ThematicParams['slug']}` + ); const translation = await loadTranslation(locale); - const { slug } = context.params as PostParams; - const thematic = await getThematicBySlug(slug); - const allThematics = await getAllThematics(); - const breadcrumbTitle = thematic.title; return { props: { - allThematics, - breadcrumbTitle, - locale, - thematic, + currentThematic: JSON.parse(JSON.stringify(currentThematic)), + thematics: JSON.parse(JSON.stringify(allThematicsLinks)), translation, }, }; }; export const getStaticPaths: GetStaticPaths = async () => { - const allSlugs = await getAllThematicsSlug(); - const paths = getFormattedPaths(allSlugs); + const slugs = await getAllThematicsSlugs(); + const paths = slugs.map((slug) => { + return { params: { slug } }; + }); return { paths, - fallback: true, + fallback: false, }; }; -export default Thematic; +export default ThematicPage; |
