From 947a06bfdfdc5bca62c27fa2ee27f0ab9fefa0ea Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 22 Apr 2022 18:33:04 +0200 Subject: chore: add a TableOfContents component --- src/utils/hooks/use-headings-tree.tsx | 153 ++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/utils/hooks/use-headings-tree.tsx (limited to 'src/utils/hooks/use-headings-tree.tsx') diff --git a/src/utils/hooks/use-headings-tree.tsx b/src/utils/hooks/use-headings-tree.tsx new file mode 100644 index 0000000..5506e8b --- /dev/null +++ b/src/utils/hooks/use-headings-tree.tsx @@ -0,0 +1,153 @@ +import { slugify } from '@utils/helpers/slugify'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +export type Heading = { + /** + * The heading depth. + */ + depth: number; + /** + * The heading id. + */ + id: string; + /** + * The heading children. + */ + children: Heading[]; + /** + * The heading title. + */ + title: string; +}; + +/** + * Get the headings tree of the given HTML element. + * + * @param {HTMLElement} wrapper - An HTML element that contains the headings. + * @returns {Heading[]} The headings tree. + */ +const useHeadingsTree = (wrapper: HTMLElement): Heading[] => { + const depths = useMemo(() => ['h2', 'h3', 'h4', 'h5', 'h6'], []); + const [allHeadings, setAllHeadings] = + useState>(); + const [headingsTree, setHeadingsTree] = useState([]); + + useEffect(() => { + const query = depths.join(', '); + const result: NodeListOf = + wrapper.querySelectorAll(query); + setAllHeadings(result); + }, [depths, wrapper]); + + const getDepth = useCallback( + /** + * Retrieve the heading element depth. + * + * @param {HTMLHeadingElement} el - An heading element. + * @returns {number} The heading depth. + */ + (el: HTMLHeadingElement): number => { + return depths.findIndex((depth) => depth === el.localName); + }, + [depths] + ); + + const formatHeadings = useCallback( + /** + * Convert a list of headings into an array of Heading objects. + * + * @param {NodeListOf} headings - A list of headings. + * @returns {Heading[]} An array of Heading objects. + */ + (headings: NodeListOf): Heading[] => { + const formattedHeadings: Heading[] = []; + + Array.from(headings).forEach((heading) => { + const title: string = heading.textContent!; + const id = slugify(title); + const depth = getDepth(heading); + const children: Heading[] = []; + + heading.id = id; + + formattedHeadings.push({ + depth, + id, + children, + title, + }); + }); + + return formattedHeadings; + }, + [getDepth] + ); + + const buildSubTree = useCallback( + /** + * Build the heading subtree. + * + * @param {Heading} parent - The heading parent. + * @param {Heading} currentHeading - The current heading element. + */ + (parent: Heading, currentHeading: Heading): void => { + if (parent.depth === currentHeading.depth - 1) { + parent.children.push(currentHeading); + } else { + const lastItem = parent.children[parent.children.length - 1]; + buildSubTree(lastItem, currentHeading); + } + }, + [] + ); + + const buildTree = useCallback( + /** + * Build a heading tree. + * + * @param {Heading[]} headings - An array of Heading objects. + * @returns {Heading[]} The headings tree. + */ + (headings: Heading[]): Heading[] => { + const tree: Heading[] = []; + + headings.forEach((heading) => { + if (heading.depth === 0) { + tree.push(heading); + } else { + const lastItem = tree[tree.length - 1]; + buildSubTree(lastItem, heading); + } + }); + + return tree; + }, + [buildSubTree] + ); + + const getHeadingsTree = useCallback( + /** + * Retrieve a headings tree from a list of headings element. + * + * @param {NodeListOf} headings - A headings list. + * @returns {Heading[]} The headings tree. + */ + (headings: NodeListOf): Heading[] => { + const formattedHeadings = formatHeadings(headings); + + return buildTree(formattedHeadings); + }, + [formatHeadings, buildTree] + ); + + useEffect(() => { + if (allHeadings) { + const headingsList = getHeadingsTree(allHeadings); + setHeadingsTree(headingsList); + } + }, [allHeadings, getHeadingsTree]); + + return headingsTree; +}; + +export default useHeadingsTree; -- cgit v1.2.3 From 9c8921db92d16b07ffc2a63ff3c80c4dcdd9ff9d Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 10 May 2022 17:38:07 +0200 Subject: chore: add Project single pages --- .../organisms/widgets/links-list-widget.tsx | 2 +- .../organisms/widgets/sharing.stories.tsx | 25 +-- src/components/organisms/widgets/sharing.test.tsx | 10 +- src/components/organisms/widgets/sharing.tsx | 25 ++- .../templates/page/page-layout.module.scss | 5 + src/pages/projets.tsx | 150 ------------- src/pages/projets/[slug].tsx | 249 +++++++++++++++++++++ src/pages/projets/index.tsx | 143 ++++++++++++ src/ts/types/app.ts | 17 +- src/ts/types/swr.ts | 5 + src/utils/helpers/projects.ts | 80 ++++--- src/utils/helpers/slugify.ts | 18 -- src/utils/helpers/strings.ts | 29 +++ src/utils/hooks/use-github-api.tsx | 30 +++ src/utils/hooks/use-headings-tree.tsx | 2 +- 15 files changed, 553 insertions(+), 237 deletions(-) delete mode 100644 src/pages/projets.tsx create mode 100644 src/pages/projets/[slug].tsx create mode 100644 src/pages/projets/index.tsx create mode 100644 src/ts/types/swr.ts delete mode 100644 src/utils/helpers/slugify.ts create mode 100644 src/utils/helpers/strings.ts create mode 100644 src/utils/hooks/use-github-api.tsx (limited to 'src/utils/hooks/use-headings-tree.tsx') diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx index 37a20fc..3f291e3 100644 --- a/src/components/organisms/widgets/links-list-widget.tsx +++ b/src/components/organisms/widgets/links-list-widget.tsx @@ -4,7 +4,7 @@ import List, { type ListItem, } from '@components/atoms/lists/list'; import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; -import { slugify } from '@utils/helpers/slugify'; +import { slugify } from '@utils/helpers/strings'; import { FC } from 'react'; import styles from './links-list-widget.module.scss'; diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx index c3c3488..47213b6 100644 --- a/src/components/organisms/widgets/sharing.stories.tsx +++ b/src/components/organisms/widgets/sharing.stories.tsx @@ -22,9 +22,13 @@ export default { type: null, }, description: 'Default widget state (expanded or collapsed).', + table: { + category: 'Options', + defaultValue: { summary: true }, + }, type: { name: 'boolean', - required: true, + required: false, }, }, level: { @@ -34,9 +38,13 @@ export default { max: 6, }, description: 'The heading level.', + table: { + category: 'Options', + defaultValue: { summary: 2 }, + }, type: { name: 'number', - required: true, + required: false, }, }, media: { @@ -49,16 +57,6 @@ export default { required: true, }, }, - title: { - control: { - type: 'text', - }, - description: 'The widget title.', - type: { - name: 'string', - required: true, - }, - }, }, decorators: [ (Story) => ( @@ -78,14 +76,11 @@ const Template: ComponentStory = (args) => ( */ export const Sharing = Template.bind({}); Sharing.args = { - expanded: true, data: { excerpt: 'Alias similique eius ducimus laudantium aspernatur. Est rem ut eum temporibus sit reprehenderit aut non molestias. Vel dolorem expedita labore quo inventore aliquid nihil nam. Possimus nobis enim quas corporis eos.', title: 'Accusantium totam nostrum', url: 'https://www.example.test', }, - level: 2, media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'], - title: 'Sharing', }; diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx index 265dbe1..48da49e 100644 --- a/src/components/organisms/widgets/sharing.test.tsx +++ b/src/components/organisms/widgets/sharing.test.tsx @@ -9,15 +9,7 @@ const postData: SharingData = { describe('Sharing', () => { it('renders a sharing widget', () => { - render( - - ); + render(); expect( screen.getByRole('link', { name: 'Share on facebook' }) ).toBeInTheDocument(); diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx index 05a3f73..85dadb0 100644 --- a/src/components/organisms/widgets/sharing.tsx +++ b/src/components/organisms/widgets/sharing.tsx @@ -21,11 +21,19 @@ export type SharingData = { url: string; }; -export type SharingProps = Pick & { +export type SharingProps = { /** * The page data to share. */ data: SharingData; + /** + * The widget default state. + */ + expanded?: WidgetProps['expanded']; + /** + * The HTML heading level. + */ + level?: WidgetProps['level']; /** * A list of active and ordered sharing medium. */ @@ -37,8 +45,19 @@ export type SharingProps = Pick & { * * Render a list of sharing links inside a widget. */ -const Sharing: FC = ({ data, media, ...props }) => { +const Sharing: FC = ({ + data, + media, + expanded = true, + level = 2, + ...props +}) => { const intl = useIntl(); + const widgetTitle = intl.formatMessage({ + defaultMessage: 'Share', + id: 'q3U6uI', + description: 'Sharing: widget title', + }); /** * Build the Diaspora sharing url with provided data. @@ -181,7 +200,7 @@ const Sharing: FC = ({ data, media, ...props }) => { }; return ( - +
    {getItems()}
); diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss index d5a1a2b..7602492 100644 --- a/src/components/templates/page/page-layout.module.scss +++ b/src/components/templates/page/page-layout.module.scss @@ -35,6 +35,11 @@ .body { grid-column: 2; + + > * + * { + margin-top: var(--spacing-sm); + margin-bottom: var(--spacing-sm); + } } .sidebar { diff --git a/src/pages/projets.tsx b/src/pages/projets.tsx deleted file mode 100644 index 996af74..0000000 --- a/src/pages/projets.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import Link from '@components/atoms/links/link'; -import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb'; -import CardsList, { - CardsListItem, -} from '@components/organisms/layout/cards-list'; -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 { ProjectCard } from '@ts/types/app'; -import { loadTranslation, Messages } from '@utils/helpers/i18n'; -import { getProjectsCard } from '@utils/helpers/projects'; -import useSettings from '@utils/hooks/use-settings'; -import { NestedMDXComponents } from 'mdx/types'; -import { GetStaticProps, NextPage } 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'; - -type ProjectsPageProps = { - projects: ProjectCard[]; - translation?: Messages; -}; - -/** - * Projects page. - */ -const ProjectsPage: NextPage = ({ projects }) => { - const intl = useIntl(); - const { dates, seo, title } = meta; - const homeLabel = intl.formatMessage({ - defaultMessage: 'Home', - description: 'Breadcrumb: home label', - id: 'j5k9Fe', - }); - const breadcrumb: BreadcrumbItem[] = [ - { id: 'home', name: homeLabel, url: '/' }, - { id: 'projects', name: title, url: '/projets' }, - ]; - - const items: CardsListItem[] = projects.map( - ({ id, meta: projectMeta, slug, title: projectTitle }) => { - const { cover, tagline, ...remainingMeta } = projectMeta; - const formattedMeta: CardsListItem['meta'] = remainingMeta.technologies - ? [ - { - id: 'technologies', - term: 'Technologies', - value: remainingMeta.technologies, - }, - ] - : undefined; - - return { - cover, - id: id as string, - meta: formattedMeta, - tagline, - title: projectTitle, - url: `/projets/${slug}`, - }; - } - ); - - const components: NestedMDXComponents = { - Links: (props) => , - }; - - const { website } = useSettings(); - const { asPath } = useRouter(); - const pageUrl = `${website.url}${asPath}`; - const pagePublicationDate = new Date(dates.publication); - const pageUpdateDate = new Date(dates.update); - - const webpageSchema: WebPage = { - '@id': `${pageUrl}`, - '@type': 'WebPage', - breadcrumb: { '@id': `${website.url}/#breadcrumb` }, - name: seo.title, - description: seo.description, - inLanguage: website.locales.default, - license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr', - reviewedBy: { '@id': `${website.url}/#branding` }, - url: `${pageUrl}`, - isPartOf: { - '@id': `${website.url}`, - }, - }; - - const articleSchema: Article = { - '@id': `${website.url}/#projects`, - '@type': 'Article', - name: meta.title, - description: seo.description, - author: { '@id': `${website.url}/#branding` }, - copyrightYear: pagePublicationDate.getFullYear(), - creator: { '@id': `${website.url}/#branding` }, - dateCreated: pagePublicationDate.toISOString(), - dateModified: pageUpdateDate.toISOString(), - datePublished: pagePublicationDate.toISOString(), - editor: { '@id': `${website.url}/#branding` }, - headline: meta.title, - inLanguage: website.locales.default, - 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 ( - } - breadcrumb={breadcrumb} - > - - {seo.title} - - - - - - -