diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-12-07 18:48:53 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-12-08 19:13:47 +0100 |
| commit | d375e5c9f162cbd84a6e6462977db56519d09f75 (patch) | |
| tree | aed9bc81c426e3e9fb60292cb244613cb8083dea /src/pages | |
| parent | b8eb008dd5927fb736e56699637f5f8549965eae (diff) | |
refactor(pages): refine Project pages
* refactor ProjectOverview component to let consumers handle the value
* extract project overview depending on Github to avoid fetching
Github API if the project is not on Github
* wrap dynamic import in a useMemo hook to avoid infinite rerender
* fix table of contents by adding a useMutationObserver hook to refresh
headings tree (without it useHeadingsTree is not retriggered once the
dynamic import is done)
* add Cypress tests
Diffstat (limited to 'src/pages')
| -rw-r--r-- | src/pages/article/[slug].tsx | 2 | ||||
| -rw-r--r-- | src/pages/cv.tsx | 2 | ||||
| -rw-r--r-- | src/pages/mentions-legales.tsx | 2 | ||||
| -rw-r--r-- | src/pages/projets/[slug].tsx | 356 | ||||
| -rw-r--r-- | src/pages/sujet/[slug].tsx | 2 | ||||
| -rw-r--r-- | src/pages/thematique/[slug].tsx | 2 |
6 files changed, 237 insertions, 129 deletions
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index 2a886aa..bd102a9 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -78,7 +78,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ data }) => { title: data.post.title, url: data.post.slug, }); - const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 }); const { attributes, className: prismClassName } = usePrism({ attributes: { 'data-toolbar-order': 'show-language,copy-to-clipboard,color-scheme', diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx index edff59f..b77aa8c 100644 --- a/src/pages/cv.tsx +++ b/src/pages/cv.tsx @@ -42,7 +42,7 @@ const DownloadLink = (chunks: ReactNode) => ( */ const CVPage: NextPageWithLayout = () => { const intl = useIntl(); - const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 }); const { dates, intro, seo, title } = meta; const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ title, diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx index d5958a6..7d46680 100644 --- a/src/pages/mentions-legales.tsx +++ b/src/pages/mentions-legales.tsx @@ -37,7 +37,7 @@ const LegalNoticePage: NextPageWithLayout = () => { url: ROUTES.LEGAL_NOTICE, }); - const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 }); const { asPath } = useRouter(); const webpageSchema = getWebPageSchema({ description: seo.description, diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx index cac6037..0c750f9 100644 --- a/src/pages/projets/[slug].tsx +++ b/src/pages/projets/[slug].tsx @@ -4,39 +4,49 @@ import type { GetStaticPaths, GetStaticProps } from 'next'; import dynamic from 'next/dynamic'; import Head from 'next/head'; import NextImage from 'next/image'; -import { useRouter } from 'next/router'; import Script from 'next/script'; -import type { ComponentType } from 'react'; +import { useMemo, type ComponentType, type FC } from 'react'; import { useIntl } from 'react-intl'; import { - getLayout, - SharingWidget, - Spinner, Heading, - ProjectOverview, - type ProjectMeta, - type Repository, + Link, + LoadingPage, + type MetaValues, Page, + PageBody, PageHeader, PageSidebar, + ProjectOverview, + SharingWidget, + SocialLink, + Spinner, + Time, TocWidget, - PageBody, + getLayout, + type ProjectOverviewProps, } from '../../components'; import { mdxComponents } from '../../components/mdx'; -import styles from '../../styles/pages/project.module.scss'; -import type { NextPageWithLayout, Project, Repos } from '../../types'; +import { fetchGithubRepoMeta } from '../../services/github'; +import styles from '../../styles/pages/projects.module.scss'; +import type { + GithubRepositoryMeta, + Maybe, + NextPageWithLayout, + Project, + ProjectMeta, +} from '../../types'; import { CONFIG } from '../../utils/config'; -import { GITHUB_PSEUDO, ROUTES } from '../../utils/constants'; import { + capitalize, getSchemaJson, getSinglePageSchema, getWebPageSchema, } from '../../utils/helpers'; import { + type Messages, getProjectData, getProjectFilenames, loadTranslation, - type Messages, } from '../../utils/helpers/server'; import { useBreadcrumb, @@ -44,139 +54,201 @@ import { useHeadingsTree, } from '../../utils/hooks'; -type ProjectPageProps = { - project: Project; - translation: Messages; -}; +const getGithubRepoInputFrom = (namespace: string) => { + const parts = namespace.split('/'); -/** - * 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: `${ROUTES.PROJECTS}/${id}`, - }); - const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + if (parts.length !== 2) + throw new Error( + 'Invalid repo. It should use the following format: owner/name.' + ); - const ProjectContent: ComponentType<MDXComponents> = dynamic( - async () => import(`../../content/projects/${id}.mdx`), - { - loading: () => <Spinner />, - } - ); + return { owner: parts[0], name: parts[1] }; +}; - const { asPath } = useRouter(); - const page = { - title: `${seo.title} - ${CONFIG.name}`, - url: `${CONFIG.url}${asPath}`, +const isValidRepo = (name: string): name is 'github' | 'gitlab' => + ['github', 'gitlab'].includes(name); + +type GithubRepoOverviewProps = Omit< + ProjectOverviewProps, + 'cover' | 'meta' | 'name' +> & + Pick<ProjectMeta, 'cover' | 'license' | 'technologies'> & { + repos: { + github: string; + gitlab?: string; + }; + title: string; }; - /** - * Retrieve the project repositories. - * - * @param {Repos} repositories - A repositories object. - * @returns {Repository[]} - An array of repositories. - */ - const getRepos = (repositories: Repos): Repository[] => { - const definedRepos: Repository[] = []; +const GithubRepoOverview: FC<GithubRepoOverviewProps> = ({ + cover, + license, + repos, + technologies, + title, + ...props +}) => { + const intl = useIntl(); + const { isLoading, meta: repoMeta } = useGithubRepoMeta( + getGithubRepoInputFrom(repos.github) + ); + const reposLabels = { + github: intl.formatMessage({ + defaultMessage: 'Github', + description: 'ProjectPage: Github repo label', + id: 'l82UU5', + }), + gitlab: intl.formatMessage({ + defaultMessage: 'Gitlab', + description: 'ProjectPage: Gitlab repo label', + id: '1msHuZ', + }), + }; + const stars = intl.formatMessage( + { + defaultMessage: + '{starsCount, plural, =0 {No stars} one {# star} other {# stars}}', + description: 'ProjectPage: stars count', + id: '4M71hp', + }, + { starsCount: repoMeta?.stargazerCount } + ); + const popularityURL = `https://github.com/${repos.github}/stargazers`; - if (repositories.github) - definedRepos.push({ - id: 'Github', - label: intl.formatMessage({ - defaultMessage: 'Github profile', - description: 'ProjectsPage: Github profile link', - id: 'Nx8Jo5', - }), - url: repositories.github, - }); + return isLoading ? ( + <Spinner> + {intl.formatMessage({ + defaultMessage: 'Loading the repository metadata...', + description: 'ProjectPage: loading repository metadata', + id: 'EET/tC', + })} + </Spinner> + ) : ( + <ProjectOverview + {...props} + cover={cover ? <NextImage {...cover} /> : undefined} + meta={{ + creationDate: repoMeta?.createdAt ? ( + <Time date={repoMeta.createdAt} /> + ) : undefined, + lastUpdateDate: repoMeta?.updatedAt ? ( + <Time date={repoMeta.updatedAt} /> + ) : undefined, + license, + popularity: ( + <> + ⭐ <Link href={popularityURL}>{stars}</Link> + </> + ), + repositories: Object.entries(repos) + .map(([key, value]): Maybe<MetaValues> => { + if (!isValidRepo(key)) return undefined; - if (repositories.gitlab) - definedRepos.push({ - id: 'Gitlab', - label: intl.formatMessage({ - defaultMessage: 'Gitlab profile', - description: 'ProjectsPage: Gitlab profile link', - id: 'sECHDg', + return { + id: key, + value: ( + <SocialLink + icon={capitalize(key)} + key={key} + label={reposLabels[key]} + url={value} + /> + ), + }; + }) + .filter((entry): entry is MetaValues => !!entry), + technologies: technologies?.map((techno) => { + return { + id: techno, + value: techno, + }; }), - url: repositories.gitlab, - }); + }} + name={title} + /> + ); +}; - return definedRepos; +type ProjectPageProps = { + data: { + githubMeta: Maybe<GithubRepositoryMeta>; + project: Project; }; + translation: Messages; +}; - const loadingRepoPopularity = intl.formatMessage({ - defaultMessage: 'Loading the repository popularity...', - description: 'ProjectsPage: loading repository popularity', - id: 'RwI3B9', - }); - - const { - isError, - isLoading, - meta: githubMeta, - } = useGithubRepoMeta({ - name: repos.github?.substring(repos.github.lastIndexOf('/') + 1) ?? '', - owner: GITHUB_PSEUDO, +/** + * Project page. + */ +const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ data }) => { + const { id, intro, meta, slug, title } = data.project; + const intl = useIntl(); + const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({ + title, + url: slug, }); + const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 }); - if (isError) return 'Error'; - if (isLoading || !githubMeta) - return <Spinner aria-label={loadingRepoPopularity} />; - - const overviewMeta: Partial<ProjectMeta> = { - creationDate: githubMeta.createdAt, - lastUpdateDate: githubMeta.updatedAt, - license, - popularity: repos.github - ? { - count: githubMeta.stargazerCount, - url: `https://github.com/${repos.github}/stargazers`, - } - : undefined, - repositories: getRepos(repos), - technologies, + const page = { + title: `${meta.seo.title} - ${CONFIG.name}`, + url: `${CONFIG.url}${slug}`, }; const webpageSchema = getWebPageSchema({ - description: seo.description, + description: meta.seo.description, locale: CONFIG.locales.defaultLocale, - slug: asPath, - title: seo.title, - updateDate: dates.update, + slug, + title: meta.seo.title, + updateDate: meta.dates.update, }); const articleSchema = getSinglePageSchema({ cover: `/projects/${id}.jpg`, - dates, + dates: meta.dates, description: intro, id: 'project', kind: 'page', locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug, title, }); const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]); - const sharingWidgetTitle = intl.formatMessage({ - defaultMessage: 'Share', - id: 'HKKkQk', - description: 'SharingWidget: widget title', - }); - const tocTitle = intl.formatMessage({ - defaultMessage: 'Table of Contents', - description: 'PageLayout: table of contents title', - id: 'eys2uX', - }); + + const messages = { + repos: { + gitlab: intl.formatMessage({ + defaultMessage: 'Gitlab', + description: 'ProjectPage: Gitlab repo label', + id: '1msHuZ', + }), + }, + widgets: { + sharingTitle: intl.formatMessage({ + defaultMessage: 'Share', + id: 'JnalJp', + description: 'ProjectPage: sharing widget title', + }), + tocTitle: intl.formatMessage({ + defaultMessage: 'Table of Contents', + description: 'PageLayout: table of contents title', + id: 'eys2uX', + }), + }, + }; + + const ProjectContent: ComponentType<MDXComponents> = useMemo( + () => + dynamic(async () => import(`../../content/projects/${id}.mdx`), { + loading: () => <LoadingPage />, + }), + [id] + ); return ( <Page breadcrumbs={breadcrumbItems} isBodyLastChild> <Head> <title>{page.title}</title> {/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */} - <meta name="description" content={seo.description} /> + <meta name="description" content={meta.seo.description} /> <meta property="og:url" content={page.url} /> {/*eslint-disable-next-line react/jsx-no-literals -- Content allowed */} <meta property="og:type" content="article" /> @@ -200,28 +272,57 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { heading={title} intro={intro} meta={{ - publicationDate: dates.publication, - updateDate: dates.update, + publicationDate: meta.dates.publication, + updateDate: meta.dates.update, }} /> <PageSidebar> <TocWidget - heading={<Heading level={3}>{tocTitle}</Heading>} + heading={<Heading level={2}>{messages.widgets.tocTitle}</Heading>} tree={tree} /> </PageSidebar> <PageBody ref={ref}> - <ProjectOverview - cover={cover ? <NextImage {...cover} /> : undefined} - meta={overviewMeta} - name={project.title} - /> + {meta.repos.github ? ( + <GithubRepoOverview + className={styles.overview} + cover={meta.cover} + license={meta.license} + repos={{ github: meta.repos.github, gitlab: meta.repos.gitlab }} + technologies={meta.technologies} + title={title} + /> + ) : ( + <ProjectOverview + className={styles.overview} + cover={meta.cover ? <NextImage {...meta.cover} /> : undefined} + meta={{ + license: meta.license, + repositories: meta.repos.gitlab ? ( + <SocialLink + // eslint-disable-next-line react/jsx-no-literals + icon="Gitlab" + label={messages.repos.gitlab} + url={meta.repos.gitlab} + /> + ) : undefined, + technologies: meta.technologies?.map((techno) => { + return { + id: techno, + value: techno, + }; + }), + }} + name={title} + /> + )} <ProjectContent components={mdxComponents} /> </PageBody> <PageSidebar> <SharingWidget + className={styles['sharing-widget']} data={{ excerpt: intro, title, url: page.url }} - heading={<Heading level={3}>{sharingWidgetTitle}</Heading>} + heading={<Heading level={2}>{messages.widgets.sharingTitle}</Heading>} media={[ 'diaspora', 'email', @@ -230,7 +331,6 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => { 'linkedin', 'twitter', ]} - className={styles.widget} /> </PageSidebar> </Page> @@ -245,10 +345,18 @@ export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({ }) => { const translation = await loadTranslation(locale); const project = await getProjectData(params ? (params.slug as string) : ''); + const githubMeta = project.meta.repos.github + ? await fetchGithubRepoMeta( + getGithubRepoInputFrom(project.meta.repos.github) + ) + : undefined; return { props: { - project, + data: { + githubMeta, + project, + }, translation, }, }; diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 8a9c2f3..43b5aa6 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -75,7 +75,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ data }) => { title: topic.title, url: `${ROUTES.TOPICS}/${topic.slug}`, }); - const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 }); if (isFallback || isLoading) return <LoadingPage />; diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index e290782..6ab349d 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -74,7 +74,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ data }) => { title: data.currentThematic.title, url: `${ROUTES.THEMATICS}/${data.currentThematic.slug}`, }); - const { ref, tree } = useHeadingsTree({ fromLevel: 2 }); + const { ref, tree } = useHeadingsTree<HTMLDivElement>({ fromLevel: 2 }); if (isFallback || isLoading) return <LoadingPage />; |
