aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/components/molecules/collapsible/collapsible.module.scss4
-rw-r--r--src/components/molecules/index.ts1
-rw-r--r--src/components/molecules/layout/index.ts2
-rw-r--r--src/components/molecules/layout/page-footer.stories.tsx57
-rw-r--r--src/components/molecules/layout/page-footer.test.tsx10
-rw-r--r--src/components/molecules/layout/page-footer.tsx18
-rw-r--r--src/components/molecules/layout/page-header.module.scss64
-rw-r--r--src/components/molecules/layout/page-header.stories.tsx154
-rw-r--r--src/components/molecules/layout/page-header.test.tsx21
-rw-r--r--src/components/molecules/layout/page-header.tsx62
-rw-r--r--src/components/templates/layout/layout.module.scss13
-rw-r--r--src/components/templates/layout/layout.test.tsx14
-rw-r--r--src/components/templates/layout/layout.tsx25
-rw-r--r--src/components/templates/page/index.ts7
-rw-r--r--src/components/templates/page/page-body.test.tsx14
-rw-r--r--src/components/templates/page/page-body.tsx23
-rw-r--r--src/components/templates/page/page-comments.stories.tsx170
-rw-r--r--src/components/templates/page/page-comments.test.tsx103
-rw-r--r--src/components/templates/page/page-comments.tsx178
-rw-r--r--src/components/templates/page/page-footer.stories.tsx41
-rw-r--r--src/components/templates/page/page-footer.test.tsx53
-rw-r--r--src/components/templates/page/page-footer.tsx54
-rw-r--r--src/components/templates/page/page-header.stories.tsx76
-rw-r--r--src/components/templates/page/page-header.test.tsx149
-rw-r--r--src/components/templates/page/page-header.tsx172
-rw-r--r--src/components/templates/page/page-layout.module.scss95
-rw-r--r--src/components/templates/page/page-layout.stories.tsx521
-rw-r--r--src/components/templates/page/page-layout.test.tsx113
-rw-r--r--src/components/templates/page/page-layout.tsx287
-rw-r--r--src/components/templates/page/page-sidebar.test.tsx14
-rw-r--r--src/components/templates/page/page-sidebar.tsx20
-rw-r--r--src/components/templates/page/page.module.scss212
-rw-r--r--src/components/templates/page/page.stories.tsx456
-rw-r--r--src/components/templates/page/page.test.tsx49
-rw-r--r--src/components/templates/page/page.tsx56
-rw-r--r--src/i18n/en.json176
-rw-r--r--src/i18n/fr.json168
-rw-r--r--src/pages/404.tsx80
-rw-r--r--src/pages/article/[slug].tsx216
-rw-r--r--src/pages/blog/index.tsx102
-rw-r--r--src/pages/blog/page/[number].tsx105
-rw-r--r--src/pages/contact.tsx91
-rw-r--r--src/pages/cv.tsx160
-rw-r--r--src/pages/index.tsx3
-rw-r--r--src/pages/mentions-legales.tsx79
-rw-r--r--src/pages/projets/[slug].tsx112
-rw-r--r--src/pages/projets/index.tsx28
-rw-r--r--src/pages/recherche/index.tsx102
-rw-r--r--src/pages/sujet/[slug].tsx153
-rw-r--r--src/pages/thematique/[slug].tsx141
-rw-r--r--src/styles/layout/_grid.scss27
-rw-r--r--src/styles/pages/project.module.scss5
-rw-r--r--src/types/app.ts2
-rw-r--r--src/utils/hooks/index.ts1
-rw-r--r--src/utils/hooks/use-reading-time.tsx56
55 files changed, 2602 insertions, 2513 deletions
diff --git a/src/components/molecules/collapsible/collapsible.module.scss b/src/components/molecules/collapsible/collapsible.module.scss
index 3c5a97c..2ee9aea 100644
--- a/src/components/molecules/collapsible/collapsible.module.scss
+++ b/src/components/molecules/collapsible/collapsible.module.scss
@@ -88,12 +88,12 @@
max-height 1.2s ease-in-out;
&--has-padding {
- margin: var(--spacing-2xs) 0;
+ margin-top: var(--spacing-2xs);
padding-block: var(--spacing-2xs);
}
&--no-padding {
- margin: var(--spacing-xs) 0;
+ margin-top: var(--spacing-xs);
}
}
}
diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts
index 04c669f..0619de8 100644
--- a/src/components/molecules/index.ts
+++ b/src/components/molecules/index.ts
@@ -8,7 +8,6 @@ export * from './copyright';
export * from './forms';
export * from './grid';
export * from './images';
-export * from './layout';
export * from './meta-list';
export * from './modals';
export * from './nav';
diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts
deleted file mode 100644
index f204f56..0000000
--- a/src/components/molecules/layout/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from './page-footer';
-export * from './page-header';
diff --git a/src/components/molecules/layout/page-footer.stories.tsx b/src/components/molecules/layout/page-footer.stories.tsx
deleted file mode 100644
index 994e888..0000000
--- a/src/components/molecules/layout/page-footer.stories.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import { Link } from '../../atoms';
-import { MetaItem, MetaList } from '../meta-list';
-import { PageFooter as PageFooterComponent } from './page-footer';
-
-/**
- * Page Footer - Storybook Meta
- */
-export default {
- title: 'Molecules/Layout',
- component: PageFooterComponent,
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the footer element.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- meta: {
- description: 'The page meta.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- },
-} as ComponentMeta<typeof PageFooterComponent>;
-
-const Template: ComponentStory<typeof PageFooterComponent> = (args) => (
- <PageFooterComponent {...args} />
-);
-
-/**
- * Page Footer Stories - With meta
- */
-export const PageFooter = Template.bind({});
-PageFooter.args = {
- children: (
- <MetaList>
- <MetaItem
- label="More posts about:"
- value={<Link href="#topic1">Topic name</Link>}
- />
- </MetaList>
- ),
-};
diff --git a/src/components/molecules/layout/page-footer.test.tsx b/src/components/molecules/layout/page-footer.test.tsx
deleted file mode 100644
index dbd20f5..0000000
--- a/src/components/molecules/layout/page-footer.test.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-import { render, screen as rtlScreen } from '../../../../tests/utils';
-import { PageFooter } from './page-footer';
-
-describe('PageFooter', () => {
- it('renders a footer element', () => {
- render(<PageFooter />);
- expect(rtlScreen.getByRole('contentinfo')).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx
deleted file mode 100644
index e0ce2ef..0000000
--- a/src/components/molecules/layout/page-footer.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { FC, ReactNode } from 'react';
-import { Footer, type FooterProps } from '../../atoms';
-
-export type PageFooterProps = Omit<FooterProps, 'children'> & {
- /**
- * The footer contents.
- */
- children?: ReactNode;
-};
-
-/**
- * PageFooter component
- *
- * Render a footer to display page meta.
- */
-export const PageFooter: FC<PageFooterProps> = ({ children, ...props }) => (
- <Footer {...props}>{children}</Footer>
-);
diff --git a/src/components/molecules/layout/page-header.module.scss b/src/components/molecules/layout/page-header.module.scss
deleted file mode 100644
index 1a90fe5..0000000
--- a/src/components/molecules/layout/page-header.module.scss
+++ /dev/null
@@ -1,64 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/placeholders";
-
-.wrapper {
- @extend %grid;
-
- &::before,
- &::after {
- content: "";
- width: 100%;
- height: 100%;
- background: var(--color-bg-secondary);
- border-top: fun.convert-px(3) solid var(--color-border-light);
- border-bottom: fun.convert-px(3) solid var(--color-border-light);
- }
-
- &::before {
- grid-column: 1;
- justify-self: start;
- border-right: fun.convert-px(3) solid var(--color-border-light);
- }
-
- &::after {
- grid-column: 3;
- justify-self: end;
- border-left: fun.convert-px(3) solid var(--color-border-light);
- }
-}
-
-.body {
- grid-column: 2;
- display: flex;
- flex-flow: column wrap;
- row-gap: var(--spacing-sm);
-}
-
-.title {
- display: flex;
- flex-flow: row wrap;
- align-items: center;
- position: relative;
-
- &::before,
- &::after {
- content: "";
- width: 100%;
- height: fun.convert-px(4);
- background: radial-gradient(
- ellipse at center,
- var(--color-primary-light),
- var(--color-primary-dark)
- );
- }
-}
-
-.meta {
- font-size: var(--font-size-sm);
-}
-
-.intro {
- > *:last-child {
- margin-bottom: 0;
- }
-}
diff --git a/src/components/molecules/layout/page-header.stories.tsx b/src/components/molecules/layout/page-header.stories.tsx
deleted file mode 100644
index 97eae5a..0000000
--- a/src/components/molecules/layout/page-header.stories.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import { MetaItem, MetaList } from '../meta-list';
-import { PageHeader } from './page-header';
-
-/**
- * Page Header - Storybook Meta
- */
-export default {
- title: 'Molecules/Layout/PageHeader',
- component: PageHeader,
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the header element.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- intro: {
- control: {
- type: 'text',
- },
- description: 'The page introduction.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- meta: {
- description: 'The page metadata.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The page title.',
- type: {
- name: 'string',
- required: true,
- },
- },
- },
-} as ComponentMeta<typeof PageHeader>;
-
-const Template: ComponentStory<typeof PageHeader> = (args) => (
- <PageHeader {...args} />
-);
-
-/**
- * Page Header Stories - Default
- */
-export const Default = Template.bind({});
-Default.args = {
- title: 'Excepturi nesciunt illum',
-};
-
-/**
- * Page Header Stories - With introduction
- */
-export const WithIntro = Template.bind({});
-WithIntro.args = {
- intro:
- 'Minima dolor nihil. Velit atque odit totam enim. Quisquam reprehenderit ut et inventore et nihil libero exercitationem. Cumque similique magni placeat et. Et sed est cumque labore. Et quia similique.',
- title: 'Excepturi nesciunt illum',
-};
-
-/**
- * Page Header Stories - With meta
- */
-export const WithMeta = Template.bind({});
-WithMeta.args = {
- meta: (
- <MetaList>
- <MetaItem isInline label="Published on:" value="2022-04-09" />
- <MetaItem
- isInline
- label="Thematics:"
- value={[
- {
- id: 'cat-1',
- value: (
- <a key="category1" href="#cat1">
- Category 1
- </a>
- ),
- },
- {
- id: 'cat-2',
- value: (
- <a key="category2" href="#cat2">
- Category 2
- </a>
- ),
- },
- ]}
- />
- </MetaList>
- ),
- title: 'Excepturi nesciunt illum',
-};
-
-/**
- * Page Header Stories - With introduction and meta
- */
-export const WithIntroAndMeta = Template.bind({});
-WithIntroAndMeta.args = {
- intro:
- 'Minima dolor nihil. Velit atque odit totam enim. Quisquam reprehenderit ut et inventore et nihil libero exercitationem. Cumque similique magni placeat et. Et sed est cumque labore. Et quia similique.',
- meta: (
- <MetaList>
- <MetaItem isInline label="Published on:" value="2022-04-09" />
- <MetaItem
- isInline
- label="Thematics:"
- value={[
- {
- id: 'cat-1',
- value: (
- <a key="category1" href="#cat1">
- Category 1
- </a>
- ),
- },
- {
- id: 'cat-2',
- value: (
- <a key="category2" href="#cat2">
- Category 2
- </a>
- ),
- },
- ]}
- />
- </MetaList>
- ),
- title: 'Excepturi nesciunt illum',
-};
diff --git a/src/components/molecules/layout/page-header.test.tsx b/src/components/molecules/layout/page-header.test.tsx
deleted file mode 100644
index 82aa7e1..0000000
--- a/src/components/molecules/layout/page-header.test.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-import { render, screen as rtlScreen } from '../../../../tests/utils';
-import { PageHeader } from './page-header';
-
-const title = 'Non nemo amet';
-const intro =
- 'Suscipit omnis minima doloribus commodi. Laudantium similique ut enim voluptatem soluta maxime autem et.';
-
-describe('PageHeader', () => {
- it('renders a title', () => {
- render(<PageHeader title={title} intro={intro} />);
- expect(rtlScreen.getByRole('heading', { level: 1 })).toHaveTextContent(
- title
- );
- });
-
- it('renders an introduction', () => {
- render(<PageHeader title={title} intro={intro} />);
- expect(rtlScreen.getByText(intro)).toBeInTheDocument();
- });
-});
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
deleted file mode 100644
index e70d66c..0000000
--- a/src/components/molecules/layout/page-header.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import type { FC, ReactNode } from 'react';
-import { Header, Heading } from '../../atoms';
-import styles from './page-header.module.scss';
-
-export type PageHeaderProps = {
- /**
- * Set additional classnames to the header element.
- */
- className?: string;
- /**
- * The page introduction.
- */
- intro?: string | ReactNode;
- /**
- * The page metadata.
- */
- meta?: ReactNode;
- /**
- * The page title.
- */
- title: ReactNode;
-};
-
-/**
- * PageHeader component
- *
- * Render a header element with page title, meta and intro.
- */
-export const PageHeader: FC<PageHeaderProps> = ({
- className = '',
- intro,
- meta,
- title,
-}) => {
- const headerClass = `${styles.wrapper} ${className}`;
-
- const getIntro = () => {
- if (typeof intro === 'string')
- return (
- <div
- className={styles.intro}
- /* eslint-disable-next-line react/no-danger -- Not safe but intro can
- * contains links or formatting so we need it. */
- dangerouslySetInnerHTML={{ __html: intro }}
- />
- );
-
- return <div className={styles.intro}>{intro}</div>;
- };
-
- return (
- <Header className={headerClass}>
- <div className={styles.body}>
- <Heading className={styles.title} level={1}>
- {title}
- </Heading>
- {meta}
- {intro ? getIntro() : null}
- </div>
- </Header>
- );
-};
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 03276bf..69c4ef0 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -90,19 +90,6 @@
flex: 1;
}
-.article {
- &--grid {
- @extend %grid;
-
- grid-auto-flow: column dense;
- align-items: baseline;
- }
-
- &--padding {
- padding-bottom: var(--spacing-lg);
- }
-}
-
.footer {
display: flex;
flex-flow: column wrap;
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 6a257f0..d3abe1d 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it } from '@jest/globals';
-import { render, screen } from '../../../../tests/utils';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
import { Layout } from './layout';
const body =
@@ -8,28 +8,28 @@ const body =
describe('Layout', () => {
it('renders the website header', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('banner')).toBeInTheDocument();
+ expect(rtlScreen.getByRole('banner')).toBeInTheDocument();
});
it('renders the website main content', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('main')).toBeInTheDocument();
+ expect(rtlScreen.getByRole('main')).toBeInTheDocument();
});
it('renders the website footer', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('contentinfo')).toBeInTheDocument();
+ expect(rtlScreen.getByRole('contentinfo')).toBeInTheDocument();
});
it('renders a skip to content link', () => {
render(<Layout>{body}</Layout>);
expect(
- screen.getByRole('link', { name: 'Skip to content' })
+ rtlScreen.getByRole('link', { name: 'Skip to content' })
).toBeInTheDocument();
});
- it('renders an article', () => {
+ it('renders its body', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('article')).toHaveTextContent(body);
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
});
});
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 055b1a1..953b0db 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -65,14 +65,6 @@ export type LayoutProps = {
* @default false
*/
isHome?: boolean;
- /**
- * Determine if article has a comments section.
- */
- withExtraPadding?: boolean;
- /**
- * Determine if article should use grid. Default: false.
- */
- useGrid?: boolean;
};
/**
@@ -80,17 +72,10 @@ export type LayoutProps = {
*
* Render the base layout used by all pages.
*/
-export const Layout: FC<LayoutProps> = ({
- children,
- withExtraPadding = false,
- isHome,
- useGrid = false,
-}) => {
+export const Layout: FC<LayoutProps> = ({ children, isHome }) => {
const router = useRouter();
const intl = useIntl();
const { baseline, copyright, locales, name, url } = CONFIG;
- const articleGridClass = useGrid ? 'article--grid' : '';
- const articleCommentsClass = withExtraPadding ? 'article--padding' : '';
const skipToContent = intl.formatMessage({
defaultMessage: 'Skip to content',
@@ -455,11 +440,7 @@ export const Layout: FC<LayoutProps> = ({
</div>
</Header>
<Main id="main" className={styles.main}>
- <article
- className={`${styles[articleGridClass]} ${styles[articleCommentsClass]}`}
- >
- {children}
- </article>
+ {children}
</Main>
<Footer className={styles.footer}>
<Colophon
@@ -495,5 +476,5 @@ export const Layout: FC<LayoutProps> = ({
*/
export const getLayout = (
page: ReactElement,
- props: NextPageWithLayoutOptions
+ props?: NextPageWithLayoutOptions
) => <Layout {...props}>{page}</Layout>;
diff --git a/src/components/templates/page/index.ts b/src/components/templates/page/index.ts
index cdd5a64..3b26326 100644
--- a/src/components/templates/page/index.ts
+++ b/src/components/templates/page/index.ts
@@ -1 +1,6 @@
-export * from './page-layout';
+export * from './page';
+export * from './page-body';
+export * from './page-comments';
+export * from './page-footer';
+export * from './page-header';
+export * from './page-sidebar';
diff --git a/src/components/templates/page/page-body.test.tsx b/src/components/templates/page/page-body.test.tsx
new file mode 100644
index 0000000..28a47d7
--- /dev/null
+++ b/src/components/templates/page/page-body.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { PageBody } from './page-body';
+
+describe('PageBody', () => {
+ it('renders its contents', () => {
+ const body =
+ 'Consectetur deleniti laboriosam vel velit optio voluptate qui. Possimus voluptatem eos enim labore debitis iure eveniet aspernatur quibusdam. Accusamus dolore quos explicabo recusandae in illo ipsam incidunt.';
+
+ render(<PageBody>{body}</PageBody>);
+
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/templates/page/page-body.tsx b/src/components/templates/page/page-body.tsx
new file mode 100644
index 0000000..df69731
--- /dev/null
+++ b/src/components/templates/page/page-body.tsx
@@ -0,0 +1,23 @@
+import {
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+ forwardRef,
+} from 'react';
+import styles from './page.module.scss';
+
+export type PageBodyProps = HTMLAttributes<HTMLDivElement>;
+
+const PageBodyWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ PageBodyProps
+> = ({ children, className = '', ...props }, ref) => {
+ const bodyClass = `${styles.body} ${className}`;
+
+ return (
+ <div {...props} className={bodyClass} ref={ref}>
+ {children}
+ </div>
+ );
+};
+
+export const PageBody = forwardRef(PageBodyWithRef);
diff --git a/src/components/templates/page/page-comments.stories.tsx b/src/components/templates/page/page-comments.stories.tsx
new file mode 100644
index 0000000..362f0a4
--- /dev/null
+++ b/src/components/templates/page/page-comments.stories.tsx
@@ -0,0 +1,170 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import type { CommentData } from '../../organisms/comments-list';
+import { Page } from './page';
+import { PageComments } from './page-comments';
+
+/**
+ * PageComments - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page/Comments Section',
+ component: PageComments,
+ argTypes: {
+ comments: {
+ control: {
+ type: null,
+ },
+ description: 'An array of comments.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ pageId: {
+ control: {
+ type: 'number',
+ },
+ description: 'Define the page id in the database.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof PageComments>;
+
+const Template: ComponentStory<typeof PageComments> = (args) => (
+ <Page>
+ <PageComments {...args} />
+ </Page>
+);
+
+const comments = [
+ {
+ author: {
+ name: 'Milan0',
+ avatar: {
+ alt: 'Milan0 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
+ },
+ },
+ content: 'Fugit veniam quas qui dolor explicabo.',
+ id: 1,
+ isApproved: true,
+ publicationDate: '2023-01-23',
+ replies: [
+ {
+ author: { name: 'Haskell42' },
+ content: 'Error quas accusamus nesciunt enim quae a.',
+ id: 25,
+ isApproved: true,
+ publicationDate: '2023-02-04',
+ },
+ {
+ author: { name: 'Hanna49', website: 'https://www.armandphilippot.com' },
+ content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.',
+ id: 30,
+ isApproved: true,
+ publicationDate: '2023-03-10',
+ },
+ ],
+ },
+ {
+ author: {
+ name: 'Corrine9',
+ avatar: {
+ alt: 'Corrine9 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
+ },
+ },
+ content:
+ 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
+ id: 2,
+ isApproved: true,
+ publicationDate: '2023-04-20',
+ },
+ {
+ author: { name: 'Presley12' },
+ content:
+ 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
+ id: 3,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ replies: [
+ {
+ author: {
+ name: 'Ana_Haley33',
+ avatar: {
+ alt: 'Ana_Haley33 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg',
+ },
+ },
+ content: 'Ab ea et fugit autem.',
+ id: 17,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ },
+ {
+ author: { name: 'Santos.Harris17' },
+ content:
+ 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.',
+ id: 18,
+ isApproved: false,
+ publicationDate: '2023-05-02',
+ },
+ ],
+ },
+ {
+ author: { name: 'Julius.Borer' },
+ content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
+ id: 4,
+ isApproved: true,
+ publicationDate: '2023-06-15',
+ },
+ {
+ author: { name: 'Geo87' },
+ content:
+ 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
+ id: 5,
+ isApproved: false,
+ publicationDate: '2023-06-16',
+ },
+ {
+ author: { name: 'Kurt.Keeling' },
+ content: 'Eligendi repellat officiis amet.',
+ id: 6,
+ isApproved: true,
+ publicationDate: '2023-06-17',
+ },
+] satisfies CommentData[];
+
+/**
+ * PageComments Stories - Without comments
+ */
+export const WithoutComments = Template.bind({});
+WithoutComments.args = {
+ comments: [],
+ pageId: 1,
+};
+
+/**
+ * PageComments Stories - With comments
+ */
+export const WithComments = Template.bind({});
+WithComments.args = {
+ comments,
+ depth: 2,
+ pageId: 1,
+};
+
+/**
+ * PageComments Stories - With comments closed
+ */
+export const WithCommentsClosed = Template.bind({});
+WithCommentsClosed.args = {
+ areCommentsClosed: true,
+ comments,
+ depth: 2,
+ pageId: 1,
+};
diff --git a/src/components/templates/page/page-comments.test.tsx b/src/components/templates/page/page-comments.test.tsx
new file mode 100644
index 0000000..32e61d7
--- /dev/null
+++ b/src/components/templates/page/page-comments.test.tsx
@@ -0,0 +1,103 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import type { CommentData } from '../../organisms/comments-list';
+import { PageComments } from './page-comments';
+
+const comments = [
+ {
+ author: {
+ name: 'Milan0',
+ avatar: {
+ alt: 'Milan0 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
+ },
+ },
+ content: 'Fugit veniam quas qui dolor explicabo.',
+ id: 1,
+ isApproved: true,
+ publicationDate: '2023-01-23',
+ replies: [],
+ },
+ {
+ author: {
+ name: 'Corrine9',
+ avatar: {
+ alt: 'Corrine9 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
+ },
+ },
+ content:
+ 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
+ id: 2,
+ isApproved: true,
+ publicationDate: '2023-04-20',
+ },
+ {
+ author: { name: 'Presley12' },
+ content:
+ 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
+ id: 3,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ replies: [],
+ },
+ {
+ author: { name: 'Julius.Borer' },
+ content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
+ id: 4,
+ isApproved: true,
+ publicationDate: '2023-06-15',
+ },
+ {
+ author: { name: 'Geo87' },
+ content:
+ 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
+ id: 5,
+ isApproved: false,
+ publicationDate: '2023-06-16',
+ },
+ {
+ author: { name: 'Kurt.Keeling' },
+ content: 'Eligendi repellat officiis amet.',
+ id: 6,
+ isApproved: true,
+ publicationDate: '2023-06-17',
+ },
+] satisfies CommentData[];
+
+describe('PageComments', () => {
+ it('renders a list of comments with a form', () => {
+ render(<PageComments comments={comments} pageId={1} />);
+
+ const headings = rtlScreen.getAllByRole('heading', { level: 2 });
+
+ expect(headings).toHaveLength(2);
+ expect(headings[0]).toHaveTextContent(`${comments.length} comments`);
+ expect(headings[1]).toHaveTextContent('Leave a comment');
+ expect(rtlScreen.getAllByRole('listitem')).toHaveLength(comments.length);
+ expect(rtlScreen.getByRole('form')).toHaveAccessibleName('Comment form');
+ });
+
+ it('can disable the comment form when comments are closed', () => {
+ render(<PageComments areCommentsClosed comments={comments} pageId={1} />);
+
+ expect(rtlScreen.getAllByRole('heading', { level: 2 })).toHaveLength(1);
+ expect(rtlScreen.queryByRole('form')).not.toBeInTheDocument();
+ });
+
+ it('can render a link to the comment form when there are no comments', () => {
+ render(<PageComments comments={[]} pageId={1} />);
+
+ expect(
+ rtlScreen.getAllByRole('heading', { level: 2 })[0]
+ ).toHaveTextContent('No comments');
+ expect(rtlScreen.queryByRole('listitem')).not.toBeInTheDocument();
+
+ const formSection = rtlScreen.getByRole('form').parentElement;
+
+ expect(formSection?.id).not.toBeUndefined();
+ expect(
+ rtlScreen.getByRole('link', { name: 'Be the first!' })
+ ).toHaveAttribute('href', `#${formSection?.id}`);
+ });
+});
diff --git a/src/components/templates/page/page-comments.tsx b/src/components/templates/page/page-comments.tsx
new file mode 100644
index 0000000..bc715e8
--- /dev/null
+++ b/src/components/templates/page/page-comments.tsx
@@ -0,0 +1,178 @@
+import {
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+ forwardRef,
+ type ReactNode,
+ useCallback,
+} from 'react';
+import { useIntl } from 'react-intl';
+import { sendComment } from '../../../services/graphql';
+import type { SendCommentInput } from '../../../types';
+import { Heading, Link } from '../../atoms';
+import { Card, CardBody } from '../../molecules';
+import {
+ type CommentData,
+ CommentsList,
+ type CommentsListProps,
+} from '../../organisms/comments-list';
+import { CommentForm, type CommentFormSubmit } from '../../organisms/forms';
+import styles from './page.module.scss';
+
+const link = (chunks: ReactNode) => (
+ // eslint-disable-next-line react/jsx-no-literals
+ <Link href="#comment-form-section">{chunks}</Link>
+);
+
+export type PageCommentsProps = Omit<
+ HTMLAttributes<HTMLDivElement>,
+ 'children' | 'onSubmit'
+> &
+ Pick<CommentsListProps, 'depth'> & {
+ /**
+ * Should the comments form be removed from the page?
+ *
+ * @default false
+ */
+ areCommentsClosed?: boolean;
+ /**
+ * The page comments.
+ */
+ comments: CommentData[];
+ /**
+ * The database page id.
+ */
+ pageId: number;
+ };
+
+const PageCommentsWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ PageCommentsProps
+> = (
+ {
+ areCommentsClosed = false,
+ className = '',
+ comments,
+ depth,
+ pageId,
+ ...props
+ },
+ ref
+) => {
+ const wrapperClass = `${styles.comments} ${className}`;
+ const commentsCount =
+ comments.length +
+ comments.reduce(
+ (accumulator, currentValue) =>
+ accumulator + (currentValue.replies?.length ?? 0),
+ 0
+ );
+ const intl = useIntl();
+ const commentsListTitle = intl.formatMessage(
+ {
+ defaultMessage:
+ '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}',
+ description: 'PageComments: the section title of the comments list',
+ id: 'H4pKJP',
+ },
+ { commentsCount }
+ );
+ const commentFormSectionTitle = intl.formatMessage({
+ defaultMessage: 'Leave a comment',
+ description: 'PageComments: the section title of the comment form',
+ id: 'Y7XdNp',
+ });
+ const commentFormTitle = intl.formatMessage({
+ defaultMessage: 'Comment form',
+ description: 'PageComments: an accessible name for the comment form',
+ id: 'o+wCJz',
+ });
+ const noCommentsYet = intl.formatMessage<ReactNode>(
+ {
+ defaultMessage: 'No comments yet. <link>Be the first!</link>',
+ id: 'w+BpPg',
+ description: 'PageComments: no comments text',
+ },
+ {
+ link,
+ }
+ );
+
+ const saveComment: CommentFormSubmit = useCallback(
+ async (data) => {
+ const commentData: SendCommentInput = {
+ author: data.author,
+ authorEmail: data.email,
+ authorUrl: data.website ?? '',
+ clientMutationId: 'comment',
+ commentOn: pageId,
+ content: data.comment,
+ parent: data.parentId,
+ };
+ const { comment, success } = await sendComment(commentData);
+ const successPrefix = intl.formatMessage({
+ defaultMessage: 'Thanks, your comment was successfully sent.',
+ description: 'PageComments: comment form success message',
+ id: 'ZcFroC',
+ });
+ const successMessage = comment?.approved
+ ? intl.formatMessage({
+ defaultMessage: 'It has been approved.',
+ id: 'UgJwSU',
+ description: 'PageComments: comment approved.',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'It is now awaiting moderation.',
+ id: '/EfcyW',
+ description: 'PageComments: comment awaiting moderation',
+ });
+
+ return {
+ messages: {
+ success: `${successPrefix} ${successMessage}`,
+ },
+ validator: () => success,
+ };
+ },
+ [intl, pageId]
+ );
+
+ return (
+ <div {...props} className={wrapperClass} ref={ref}>
+ <section className={styles.section}>
+ <Heading className={styles.heading} level={2}>
+ {commentsListTitle}
+ </Heading>
+ {comments.length ? (
+ <CommentsList
+ areRepliesForbidden={areCommentsClosed}
+ comments={comments}
+ depth={depth}
+ onSubmit={saveComment}
+ />
+ ) : (
+ <Card variant={2}>
+ <CardBody>{noCommentsYet}</CardBody>
+ </Card>
+ )}
+ </section>
+ {areCommentsClosed ? null : (
+ <section
+ className={styles.section}
+ // eslint-disable-next-line react/jsx-no-literals
+ id="comment-form-section"
+ >
+ <Heading className={styles.heading} level={2}>
+ {commentFormSectionTitle}
+ </Heading>
+ <CommentForm
+ aria-label={commentFormTitle}
+ className={styles.form}
+ onSubmit={saveComment}
+ />
+ </section>
+ )}
+ </div>
+ );
+};
+
+export const PageComments = forwardRef(PageCommentsWithRef);
diff --git a/src/components/templates/page/page-footer.stories.tsx b/src/components/templates/page/page-footer.stories.tsx
new file mode 100644
index 0000000..aee8979
--- /dev/null
+++ b/src/components/templates/page/page-footer.stories.tsx
@@ -0,0 +1,41 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Page } from './page';
+import { PageFooter } from './page-footer';
+
+/**
+ * PageFooter - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page/Footer',
+ component: PageFooter,
+ argTypes: {
+ readMoreAbout: {
+ control: {
+ type: null,
+ },
+ description: 'An array of page links.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof PageFooter>;
+
+const Template: ComponentStory<typeof PageFooter> = (args) => (
+ <Page>
+ <PageFooter {...args} />
+ </Page>
+);
+
+/**
+ * PageFooter Stories - Footer
+ */
+export const Footer = Template.bind({});
+Footer.args = {
+ readMoreAbout: [
+ { id: 1, name: 'Topic 1', url: '#topic1' },
+ { id: 2, name: 'Topic 2', url: '#topic2' },
+ ],
+};
diff --git a/src/components/templates/page/page-footer.test.tsx b/src/components/templates/page/page-footer.test.tsx
new file mode 100644
index 0000000..4af0e82
--- /dev/null
+++ b/src/components/templates/page/page-footer.test.tsx
@@ -0,0 +1,53 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import type { PageLink } from '../../../types';
+import { PageFooter } from './page-footer';
+
+describe('PageFooter', () => {
+ it('renders a list of links', () => {
+ const links = [
+ { id: 1, name: 'Topic 1', url: '/topic1' },
+ { id: 2, name: 'Topic 2', url: '/topic2' },
+ { id: 3, name: 'Topic 3', url: '/topic3' },
+ ] satisfies PageLink[];
+
+ render(<PageFooter readMoreAbout={links} />);
+
+ expect(rtlScreen.getByRole('term')).toHaveTextContent(
+ 'Read more posts about:'
+ );
+ expect(rtlScreen.getAllByRole('link')).toHaveLength(links.length);
+ });
+
+ it('can renders a list of links with logo', () => {
+ const links = [
+ {
+ id: 1,
+ logo: {
+ alt: 'a logo',
+ height: 480,
+ width: 640,
+ src: 'https://picsum.photos/640/480',
+ },
+ name: 'Topic 1',
+ url: '/topic1',
+ },
+ { id: 2, name: 'Topic 2', url: '/topic2' },
+ { id: 3, name: 'Topic 3', url: '/topic3' },
+ ] satisfies PageLink[];
+
+ render(<PageFooter readMoreAbout={links} />);
+
+ expect(rtlScreen.getByRole('term')).toHaveTextContent(
+ 'Read more posts about:'
+ );
+ expect(rtlScreen.getAllByRole('link')).toHaveLength(links.length);
+ expect(rtlScreen.getByRole('img')).toHaveAccessibleName(links[0].logo?.alt);
+ });
+
+ it('does not render a list when the prop length is 0', () => {
+ const { container } = render(<PageFooter readMoreAbout={[]} />);
+
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/components/templates/page/page-footer.tsx b/src/components/templates/page/page-footer.tsx
new file mode 100644
index 0000000..3bfece4
--- /dev/null
+++ b/src/components/templates/page/page-footer.tsx
@@ -0,0 +1,54 @@
+import NextImage from 'next/image';
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { useIntl } from 'react-intl';
+import type { PageLink } from '../../../types';
+import { Footer, type FooterProps, ButtonLink } from '../../atoms';
+import { MetaList, MetaItem } from '../../molecules';
+import styles from './page.module.scss';
+
+export type PageFooterProps = Omit<FooterProps, 'children'> & {
+ readMoreAbout: PageLink[];
+};
+
+const PageFooterWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ PageFooterProps
+> = ({ className = '', readMoreAbout, ...props }, ref) => {
+ const footerClass = `${styles.footer} ${className}`;
+ const intl = useIntl();
+ const metaLabel = intl.formatMessage({
+ defaultMessage: 'Read more posts about:',
+ description: 'PageFooter: the topics list label',
+ id: 'I6vhfk',
+ });
+
+ return (
+ <Footer {...props} className={footerClass} ref={ref}>
+ {readMoreAbout.length ? (
+ <MetaList>
+ <MetaItem
+ hasInlinedValues
+ label={metaLabel}
+ value={readMoreAbout.map((item) => {
+ return {
+ id: `${item.id}`,
+ value: (
+ <ButtonLink className={styles.btn} to={item.url}>
+ <>
+ {item.logo ? (
+ <NextImage {...item.logo} className={styles.logo} />
+ ) : null}
+ {item.name}
+ </>
+ </ButtonLink>
+ ),
+ };
+ })}
+ />
+ </MetaList>
+ ) : null}
+ </Footer>
+ );
+};
+
+export const PageFooter = forwardRef(PageFooterWithRef);
diff --git a/src/components/templates/page/page-header.stories.tsx b/src/components/templates/page/page-header.stories.tsx
new file mode 100644
index 0000000..3af9b47
--- /dev/null
+++ b/src/components/templates/page/page-header.stories.tsx
@@ -0,0 +1,76 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Page } from './page';
+import { PageHeader } from './page-header';
+
+/**
+ * PageHeader - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page/Header',
+ component: PageHeader,
+ argTypes: {
+ meta: {
+ control: {
+ type: null,
+ },
+ description: 'Define the page meta.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof PageHeader>;
+
+const Template: ComponentStory<typeof PageHeader> = (args) => (
+ <Page>
+ <PageHeader {...args} />
+ </Page>
+);
+
+/**
+ * PageHeader Stories - TitleOnly
+ */
+export const TitleOnly = Template.bind({});
+TitleOnly.args = {
+ heading: 'The page title',
+};
+
+/**
+ * PageHeader Stories - TitleAndIntro
+ */
+export const TitleAndIntro = Template.bind({});
+TitleAndIntro.args = {
+ heading: 'The page title',
+ intro:
+ 'Eos similique impedit dolor illo. Rerum voluptates corporis quod et molestiae eum. Ut tenetur repellat hic eum. Doloremque et illum sequi aspernatur.',
+};
+
+/**
+ * PageHeader Stories - TitleAndMeta
+ */
+export const TitleAndMeta = Template.bind({});
+TitleAndMeta.args = {
+ heading: 'The page title',
+ meta: {
+ author: 'Robin_Schroeder77',
+ publicationDate: '2023-11-15',
+ updateDate: '2023-11-16',
+ },
+};
+
+/**
+ * PageHeader Stories - All
+ */
+export const All = Template.bind({});
+All.args = {
+ heading: 'The page title',
+ intro:
+ 'Eos similique impedit dolor illo. Rerum voluptates corporis quod et molestiae eum. Ut tenetur repellat hic eum. Doloremque et illum sequi aspernatur.',
+ meta: {
+ author: 'Robin_Schroeder77',
+ publicationDate: '2023-11-15',
+ updateDate: '2023-11-16',
+ },
+};
diff --git a/src/components/templates/page/page-header.test.tsx b/src/components/templates/page/page-header.test.tsx
new file mode 100644
index 0000000..9878553
--- /dev/null
+++ b/src/components/templates/page/page-header.test.tsx
@@ -0,0 +1,149 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import { PageHeader, type PageHeaderMetaData } from './page-header';
+
+describe('PageHeader', () => {
+ it('renders the page title', () => {
+ const title = 'nostrum et impedit';
+
+ render(<PageHeader heading={title} />);
+
+ expect(rtlScreen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ title
+ );
+ });
+
+ it('can render an introduction', () => {
+ const title = 'nostrum et impedit';
+ const intro =
+ 'Non reiciendis error eveniet deserunt vel quis debitis incidunt voluptas. Distinctio dolorem reiciendis molestias et velit. Aut distinctio autem dolore ratione neque laudantium sed. Asperiores quo qui omnis maiores.';
+
+ render(<PageHeader heading={title} intro={intro} />);
+
+ expect(rtlScreen.getByText(intro)).toBeInTheDocument();
+ });
+
+ it('can render a meta for the author', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ author: 'Edward_Hansen72',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Written by:');
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.author);
+ });
+
+ it('can render a meta for the publication date', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ publicationDate: '2023-11-19',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Published on:');
+ });
+
+ it('can render a meta for the thematics', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ thematics: [
+ { id: 1, name: 'Thematic 1', url: '#thematic1' },
+ { id: 2, name: 'Thematic 2', url: '#thematic2' },
+ ],
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Thematics:');
+ expect(rtlScreen.getAllByRole('definition')).toHaveLength(
+ meta.thematics.length
+ );
+ });
+
+ it('can render a meta for the posts total', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ total: 40,
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Total:');
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(
+ new RegExp(`${meta.total}`)
+ );
+ });
+
+ it('can render a meta for the update date', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ updateDate: '2023-11-20',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Updated on:');
+ });
+
+ it('can render a meta for the website', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ website: 'https://example.test',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Website:');
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.website);
+ });
+
+ it('can render a meta for the reading time', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ wordsCount: 640,
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Reading time:');
+ });
+
+ it('does not render an undefined meta', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ author: undefined,
+ publicationDate: '2023-11-20',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ // Author is invalid
+ Object.keys(meta).length - 1
+ );
+ });
+});
diff --git a/src/components/templates/page/page-header.tsx b/src/components/templates/page/page-header.tsx
new file mode 100644
index 0000000..6effc9e
--- /dev/null
+++ b/src/components/templates/page/page-header.tsx
@@ -0,0 +1,172 @@
+import {
+ type ForwardRefRenderFunction,
+ forwardRef,
+ type ReactNode,
+} from 'react';
+import { useIntl } from 'react-intl';
+import type { PageLink } from '../../../types';
+import { getReadingTimeFrom } from '../../../utils/helpers';
+import { Header, Heading, type HeaderProps, Link, Time } from '../../atoms';
+import { MetaList, MetaItem } from '../../molecules';
+import styles from './page.module.scss';
+
+export type PageHeaderMetaData = {
+ author: string;
+ publicationDate: string;
+ thematics: PageLink[];
+ total: number;
+ updateDate: string;
+ website: string;
+ wordsCount: number;
+};
+
+export type PageHeaderProps = Omit<HeaderProps, 'children'> & {
+ /**
+ * The page main title.
+ */
+ heading: ReactNode;
+ /**
+ * The page introduction.
+ */
+ intro?: ReactNode;
+ /**
+ * The page meta.
+ */
+ meta?: Partial<PageHeaderMetaData>;
+};
+
+const PageHeaderWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ PageHeaderProps
+> = ({ className = '', heading, intro, meta, ...props }, ref) => {
+ const headerClass = `${styles.header} ${className}`;
+ const intl = useIntl();
+
+ return (
+ <Header {...props} className={headerClass} ref={ref}>
+ <div className={styles.header__body}>
+ <Heading className={styles.heading} level={1}>
+ {heading}
+ </Heading>
+ {meta ? (
+ <MetaList className={styles.meta}>
+ {meta.author ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Written by:',
+ description: 'PageHeader: author meta label',
+ id: '/unaGZ',
+ })}
+ value={meta.author}
+ />
+ ) : null}
+ {meta.publicationDate ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Published on:',
+ description: 'PageHeader: publication date label',
+ id: 'pUBhKy',
+ })}
+ value={<Time date={meta.publicationDate} />}
+ />
+ ) : null}
+ {meta.updateDate && meta.updateDate !== meta.publicationDate ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Updated on:',
+ description: 'PageHeader: update date label',
+ id: 'sR5hah',
+ })}
+ value={<Time date={meta.updateDate} />}
+ />
+ ) : null}
+ {meta.wordsCount ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Reading time:',
+ description: 'PageHeader: reading time label',
+ id: 'jJm8wd',
+ })}
+ value={intl.formatMessage(
+ {
+ defaultMessage:
+ '{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}',
+ description: 'PageHeader: rounded minutes count',
+ id: 'NNDqRg',
+ },
+ {
+ minutesCount: getReadingTimeFrom(
+ meta.wordsCount
+ ).inMinutes(),
+ }
+ )}
+ />
+ ) : null}
+ {meta.thematics ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage(
+ {
+ defaultMessage:
+ '{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}',
+ description: 'PageHeader: thematics label',
+ id: 'ODwkBI',
+ },
+ { thematicsCount: meta.thematics.length }
+ )}
+ value={meta.thematics.map((thematic) => {
+ return {
+ id: `thematic-${thematic.id}`,
+ value: <Link href={thematic.url}>{thematic.name}</Link>,
+ };
+ })}
+ />
+ ) : null}
+ {meta.total ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Total:',
+ description: 'PageHeader: total meta label',
+ id: 'a6DzIj',
+ })}
+ value={intl.formatMessage(
+ {
+ defaultMessage:
+ '{postsCount, plural, =0 {No posts} one {# post} other {# posts}}',
+ description: 'PageHeader: total meta value',
+ id: 'bAXtMT',
+ },
+ { postsCount: meta.total }
+ )}
+ />
+ ) : null}
+ {meta.website ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Website:',
+ description: 'PageHeader: website meta label',
+ id: '9jh0r2',
+ })}
+ value={meta.website}
+ />
+ ) : null}
+ </MetaList>
+ ) : null}
+ {typeof intro === 'string' ? (
+ // eslint-disable-next-line react/no-danger -- Intro can contain tags.
+ <div dangerouslySetInnerHTML={{ __html: intro }} />
+ ) : (
+ intro
+ )}
+ </div>
+ </Header>
+ );
+};
+
+export const PageHeader = forwardRef(PageHeaderWithRef);
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
deleted file mode 100644
index 75b996c..0000000
--- a/src/components/templates/page/page-layout.module.scss
+++ /dev/null
@@ -1,95 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
-@use "../../../styles/abstracts/placeholders";
-
-.breadcrumb {
- @extend %grid;
-
- grid-column: 1 / -1;
- width: 100%;
- padding: var(--spacing-md) 0;
-
- > * {
- grid-column: 2;
- font-size: var(--font-size-sm);
- }
-}
-
-.header {
- grid-column: 1 / -1;
- margin-bottom: var(--spacing-md);
-}
-
-.body {
- grid-column: 2;
-
- > * + * {
- margin-top: var(--spacing-sm);
- margin-bottom: var(--spacing-sm);
- }
-}
-
-.sidebar {
- grid-column: 2;
-
- &--first {
- margin-bottom: var(--spacing-xs);
-
- @include mix.media("screen") {
- @include mix.dimensions("lg") {
- grid-column: 1;
- align-self: stretch;
- margin: 0 var(--spacing-xs) var(--spacing-md);
- }
- }
- }
-
- &--last {
- margin: var(--spacing-lg) 0 0;
-
- @include mix.media("screen") {
- @include mix.dimensions("md") {
- grid-column: 3;
- align-self: stretch;
- margin: 0 var(--spacing-xs) var(--spacing-md);
- }
- }
- }
-}
-
-.footer {
- grid-column: 2;
- margin: var(--spacing-sm) 0 var(--spacing-2xs);
-}
-
-.comments {
- @extend %grid;
-
- grid-column: 1 / -1;
- margin: var(--spacing-lg) 0 0;
- padding: 0 0 var(--spacing-lg);
- background: var(--color-bg-secondary);
- border-top: fun.convert-px(3) solid var(--color-border-light);
-
- &__section {
- grid-column: 2;
- }
-
- &__title {
- width: fit-content;
- margin: var(--spacing-md) auto;
- }
-
- &__no-comments {
- text-align: center;
- }
-
- &__form {
- max-width: 40ch;
- margin: auto;
- }
-}
-
-.notice {
- margin-top: var(--spacing-sm);
-}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
deleted file mode 100644
index 6dcbeea..0000000
--- a/src/components/templates/page/page-layout.stories.tsx
+++ /dev/null
@@ -1,521 +0,0 @@
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import { ButtonLink, Heading, Link } from '../../atoms';
-import { MetaItem, MetaList } from '../../molecules';
-import { LinksWidget, PostsList, SharingWidget } from '../../organisms';
-import { LayoutBase } from '../layout/layout.stories';
-import { PageLayout as PageLayoutComponent } from './page-layout';
-
-/**
- * PageLayout - Storybook Meta
- */
-export default {
- title: 'Templates/Page',
- component: PageLayoutComponent,
- args: {
- allowComments: false,
- breadcrumbSchema: [],
- },
- argTypes: {
- allowComments: {
- control: {
- type: 'boolean',
- },
- description: 'Determine if the comment form is displayed.',
- table: {
- category: 'Options',
- defaultValue: { summary: false },
- },
- type: {
- name: 'boolean',
- required: false,
- },
- },
- bodyAttributes: {
- description: 'Set additional HTML attributes to the main content body.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the main content body.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- breadcrumb: {
- description: 'The breadcrumb items.',
- type: {
- name: 'object',
- required: true,
- value: {},
- },
- },
- breadcrumbSchema: {
- control: {
- type: null,
- },
- description: 'The JSON schema for breadcrumb items.',
- type: {
- name: 'object',
- required: true,
- value: {},
- },
- },
- children: {
- control: {
- type: 'text',
- },
- description: 'The page content.',
- type: {
- name: 'string',
- required: true,
- },
- },
- comments: {
- description: 'The page comments.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- footerMeta: {
- description: 'The metadata to display in the page footer.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- headerMeta: {
- description: 'The metadata to display in the page header.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- id: {
- control: {
- type: 'number',
- },
- description: 'The page id.',
- type: {
- name: 'number',
- required: false,
- },
- },
- intro: {
- control: {
- type: 'text',
- },
- description: 'The page introduction.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The page title.',
- type: {
- name: 'string',
- required: true,
- },
- },
- widgets: {
- control: {
- type: null,
- },
- description: 'An array of widgets to put inside the last sidebar.',
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- withToC: {
- control: {
- type: 'boolean',
- },
- description: 'Determine if the Table of Contents should be in the page.',
- table: {
- category: 'Options',
- defaultValue: { summary: false },
- },
- type: {
- name: 'boolean',
- required: false,
- },
- },
- },
- decorators: [
- (Story, context) => (
- <LayoutBase
- useGrid={true}
- withExtraPadding={!context.args.allowComments && !context.args.comments}
- {...LayoutBase.args}
- >
- <Story />
- </LayoutBase>
- ),
- ],
- parameters: {
- layout: 'fullscreen',
- },
-} as ComponentMeta<typeof PageLayoutComponent>;
-
-const Template: ComponentStory<typeof PageLayoutComponent> = (args) => (
- <PageLayoutComponent {...args} />
-);
-
-const pageTitle = 'Incidunt ad earum';
-const pageIntro =
- 'Recusandae mollitia enim quo omnis rerum enim corporis ratione quidem. Pariatur omnis quas est ut ut numquam totam. Sunt sapiente nostrum aut sunt provident perspiciatis magni illum. Quidem nihil velit quasi fugit minima sint.';
-const pageBreadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'page', url: '#', name: pageTitle },
-];
-
-/**
- * Page Layout Stories - Single Page
- */
-export const SinglePage = Template.bind({});
-SinglePage.args = {
- breadcrumb: pageBreadcrumb,
- title: pageTitle,
- intro: pageIntro,
- children: (
- <>
- <Heading level={2}>Impedit commodi rerum</Heading>
- <p>
- Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
- omnis. Laudantium rem tempore eligendi porro officia est dolorum
- assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
- nesciunt sed rerum praesentium.
- </p>
- <p>
- Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
- dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
- qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
- a ut.
- </p>
- <Heading level={2}>Et omnis ducimus</Heading>
- <p>
- Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
- accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
- totam et corrupti assumenda corporis aut illo animi.
- </p>
- <p>
- Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
- in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
- et. Accusantium fuga architecto excepturi consequatur libero est.
- </p>
- </>
- ),
- widgets: [
- <SharingWidget
- key="sidebar2-widget1"
- data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
- heading={<Heading level={3}>Share</Heading>}
- media={[
- 'diaspora',
- 'email',
- 'facebook',
- 'journal-du-hacker',
- 'linkedin',
- 'twitter',
- ]}
- />,
- ],
- withToC: true,
-};
-
-const postBreadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'blog', url: '#', name: 'Blog' },
- { id: 'post', url: '#', name: pageTitle },
-];
-
-/**
- * Page Layout Stories - Post
- */
-export const Post = Template.bind({});
-Post.args = {
- breadcrumb: postBreadcrumb,
- title: pageTitle,
- intro: pageIntro,
- headerMeta: (
- <MetaList>
- <MetaItem isInline label="Published on:" value="2020-03-14" />
- <MetaItem
- isInline
- label="Thematic:"
- value={[
- {
- id: 'cat-1',
- value: (
- <Link key="cat1" href="#">
- Cat 1
- </Link>
- ),
- },
- {
- id: 'cat-2',
- value: (
- <Link key="cat2" href="#">
- Cat 2
- </Link>
- ),
- },
- ]}
- />
- </MetaList>
- ),
- footerMeta: (
- <MetaList>
- <MetaItem
- label="Read more about:"
- value={<ButtonLink to="#">Topic 1</ButtonLink>}
- />
- </MetaList>
- ),
- children: (
- <>
- <Heading level={2}>Impedit commodi rerum</Heading>
- <p>
- Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
- omnis. Laudantium rem tempore eligendi porro officia est dolorum
- assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
- nesciunt sed rerum praesentium.
- </p>
- <p>
- Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
- dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
- qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
- a ut.
- </p>
- <Heading level={2}>Et omnis ducimus</Heading>
- <p>
- Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
- accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
- totam et corrupti assumenda corporis aut illo animi.
- </p>
- <p>
- Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
- in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
- et. Accusantium fuga architecto excepturi consequatur libero est.
- </p>
- </>
- ),
- widgets: [
- <SharingWidget
- key="sidebar2-widget1"
- data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
- heading={<Heading level={3}>Share</Heading>}
- media={[
- 'diaspora',
- 'email',
- 'facebook',
- 'journal-du-hacker',
- 'linkedin',
- 'twitter',
- ]}
- />,
- ],
- withToC: true,
- comments: [
- {
- author: {
- name: 'Milan0',
- avatar: {
- alt: 'Milan0 avatar',
- src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
- },
- },
- content: 'Fugit veniam quas qui dolor explicabo.',
- id: 1,
- isApproved: true,
- publicationDate: '2023-01-23',
- replies: [
- {
- author: { name: 'Haskell42' },
- content: 'Error quas accusamus nesciunt enim quae a.',
- id: 25,
- isApproved: true,
- publicationDate: '2023-02-04',
- },
- {
- author: {
- name: 'Hanna49',
- website: 'https://www.armandphilippot.com',
- },
- content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.',
- id: 30,
- isApproved: true,
- publicationDate: '2023-03-10',
- },
- ],
- },
- {
- author: {
- name: 'Corrine9',
- avatar: {
- alt: 'Corrine9 avatar',
- src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
- },
- },
- content:
- 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
- id: 2,
- isApproved: true,
- publicationDate: '2023-04-20',
- },
- {
- author: { name: 'Presley12' },
- content:
- 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
- id: 3,
- isApproved: true,
- publicationDate: '2023-05-01',
- replies: [
- {
- author: {
- name: 'Ana_Haley33',
- avatar: {
- alt: 'Ana_Haley33 avatar',
- src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg',
- },
- },
- content: 'Ab ea et fugit autem.',
- id: 17,
- isApproved: true,
- publicationDate: '2023-05-01',
- },
- {
- author: { name: 'Santos.Harris17' },
- content:
- 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.',
- id: 18,
- isApproved: false,
- publicationDate: '2023-05-02',
- },
- ],
- },
- {
- author: { name: 'Julius.Borer' },
- content:
- 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
- id: 4,
- isApproved: true,
- publicationDate: '2023-06-15',
- },
- {
- author: { name: 'Geo87' },
- content:
- 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
- id: 5,
- isApproved: false,
- publicationDate: '2023-06-16',
- },
- {
- author: { name: 'Kurt.Keeling' },
- content: 'Eligendi repellat officiis amet.',
- id: 6,
- isApproved: true,
- publicationDate: '2023-06-17',
- },
- ],
- allowComments: true,
-};
-
-const postsListBreadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'blog', url: '#', name: 'Blog' },
-];
-
-const blogCategories = [
- { id: 'cat1', label: 'Cat 1', url: '#' },
- { id: 'cat2', label: 'Cat 2', url: '#' },
- { id: 'cat3', label: 'Cat 3', url: '#' },
- { id: 'cat4', label: 'Cat 4', url: '#' },
-];
-
-const posts = [
- {
- excerpt:
- 'Omnis voluptatem et sit sit porro possimus quo rerum. Natus et sint cupiditate magnam omnis a consequuntur reprehenderit. Ex omnis voluptatem itaque id laboriosam qui dolorum facilis architecto. Impedit aliquid et qui quae dolorum accusamus rerum.',
- heading: 'Post 1',
- id: 'post1',
- meta: { publicationDate: '2023-11-06' },
- url: '#post1',
- },
- {
- excerpt:
- 'Nobis omnis excepturi deserunt laudantium unde totam quam. Voluptates maiores minima voluptatem nihil ea voluptatem similique. Praesentium ratione necessitatibus et et dolore voluptas illum dignissimos ipsum. Eius tempore ex.',
- heading: 'Post 2',
- id: 'post2',
- meta: { publicationDate: '2023-11-05' },
- url: '#post2',
- },
- {
- excerpt:
- 'Doloremque est dolorum explicabo. Laudantium quos delectus odit esse fugit officiis. Fugit provident vero harum atque. Eos nam qui sit ut minus voluptas. Reprehenderit rerum ut nostrum. Eos dolores mollitia quia ea voluptatem rerum vel.',
- heading: 'Post 3',
- id: 'post3',
- meta: { publicationDate: '2023-11-04' },
- url: '#post3',
- },
-];
-
-/**
- * Page Layout Stories - Posts list
- */
-export const Blog = Template.bind({});
-Blog.args = {
- breadcrumb: postsListBreadcrumb,
- title: 'Blog',
- headerMeta: (
- <MetaList>
- <MetaItem isInline label="Total:" value={`${posts.length}`} />
- </MetaList>
- ),
- children: <PostsList posts={posts} sortByYear />,
- widgets: [
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- Categories
- </Heading>
- }
- items={blogCategories}
- key="sidebar-widget1"
- />,
- ],
-};
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
deleted file mode 100644
index c7d7a65..0000000
--- a/src/components/templates/page/page-layout.test.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-import type { BreadcrumbList } from 'schema-dts';
-import { render, screen as rtlScreen } from '../../../../tests/utils';
-import { PageLayout } from './page-layout';
-
-const title = 'Incidunt ad earum';
-const breadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'page', url: '#', name: title },
-];
-const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
-const children =
- 'Reprehenderit aut quis aperiam magnam quia id. Vero enim animi placeat quia. Laborum sit odio minima. Dolores et debitis eaque iste quidem. Omnis aliquam illum porro ea non. Quaerat totam iste quos ex facilis officia accusantium.';
-
-describe('PageLayout', () => {
- it('renders the page title', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('heading', { level: 1, name: title })
- ).toBeInTheDocument();
- });
-
- it('renders the page content', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- >
- {children}
- </PageLayout>
- );
- expect(rtlScreen.getByText(children)).toBeInTheDocument();
- });
-
- it('renders the breadcrumb', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('navigation', { name: 'Breadcrumb' })
- ).toBeInTheDocument();
- });
-
- it('renders the table of contents', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- withToC={true}
- >
- {children}
- </PageLayout>
- );
- expect(rtlScreen.getByText(/Table of Contents/i)).toBeInTheDocument();
- });
-
- it('renders the comment form', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- allowComments={true}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('form', { name: /Comment form/i })
- ).toBeInTheDocument();
- });
-
- it('renders the comments list', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- allowComments={true}
- comments={[
- {
- author: { name: 'Burley40' },
- content: 'Veritatis praesentium non autem ut.',
- id: 1,
- isApproved: true,
- publicationDate: '2023-11-02',
- },
- ]}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('heading', { level: 2, name: /Comments/i })
- ).toBeInTheDocument();
- });
-});
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
deleted file mode 100644
index 75d308e..0000000
--- a/src/components/templates/page/page-layout.tsx
+++ /dev/null
@@ -1,287 +0,0 @@
-/* eslint-disable max-statements */
-import Script from 'next/script';
-import {
- type FC,
- type HTMLAttributes,
- type ReactNode,
- useCallback,
-} from 'react';
-import { useIntl } from 'react-intl';
-import type { BreadcrumbList } from 'schema-dts';
-import { sendComment } from '../../../services/graphql';
-import type { SendCommentInput } from '../../../types';
-import { useHeadingsTree } from '../../../utils/hooks';
-import { Heading, Sidebar } from '../../atoms';
-import { PageFooter, PageHeader, type PageHeaderProps } from '../../molecules';
-import {
- CommentForm,
- CommentsList,
- type CommentsListProps,
- TocWidget,
- Breadcrumbs,
- type BreadcrumbsItem,
- type CommentFormSubmit,
-} from '../../organisms';
-import styles from './page-layout.module.scss';
-
-export type PageLayoutProps = {
- /**
- * True if the page accepts new comments. Default: false.
- */
- allowComments?: boolean;
- /**
- * Set attributes to the page body.
- */
- bodyAttributes?: HTMLAttributes<HTMLDivElement>;
- /**
- * Set additional classnames to the body wrapper.
- */
- bodyClassName?: string;
- /**
- * The breadcrumb items.
- */
- breadcrumb: BreadcrumbsItem[];
- /**
- * The breadcrumb JSON schema.
- */
- breadcrumbSchema: BreadcrumbList['itemListElement'][];
- /**
- * The main content of the page.
- */
- children: ReactNode;
- /**
- * The page comments
- */
- comments?: CommentsListProps['comments'];
- /**
- * The footer metadata.
- */
- footerMeta?: ReactNode;
- /**
- * The header metadata.
- */
- headerMeta?: ReactNode;
- /**
- * The page id.
- */
- id?: number;
- /**
- * The page introduction.
- */
- intro?: PageHeaderProps['intro'];
- /**
- * The page title.
- */
- title: PageHeaderProps['title'];
- /**
- * An array of widgets to put in the last sidebar.
- */
- widgets?: ReactNode[];
- /**
- * Show the table of contents. Default: false.
- */
- withToC?: boolean;
-};
-
-/**
- * PageLayout component
- *
- * Render the pages layout.
- */
-export const PageLayout: FC<PageLayoutProps> = ({
- children,
- allowComments = false,
- bodyAttributes,
- bodyClassName = '',
- breadcrumb,
- breadcrumbSchema,
- comments,
- footerMeta,
- headerMeta,
- id,
- intro,
- title,
- widgets,
- withToC = false,
-}) => {
- const intl = useIntl();
- const breadcrumbsLabel = intl.formatMessage({
- defaultMessage: 'Breadcrumb',
- description: 'PageLayout: an accessible name for the breadcrumb nav.',
- id: 'm6a3BD',
- });
- const commentsTitle = intl.formatMessage({
- defaultMessage: 'Comments',
- description: 'PageLayout: comments title',
- id: '+dJU3e',
- });
- const commentFormSectionTitle = intl.formatMessage({
- defaultMessage: 'Leave a comment',
- description: 'PageLayout: comment form title',
- id: 'kzIYoQ',
- });
- const commentFormTitle = intl.formatMessage({
- defaultMessage: 'Comment form',
- description: 'PageLayout: comment form accessible name',
- id: 'l+Jcf6',
- });
- const tocTitle = intl.formatMessage({
- defaultMessage: 'Table of Contents',
- description: 'PageLayout: table of contents title',
- id: 'eys2uX',
- });
-
- const { ref: bodyRef, tree: headingsTree } = useHeadingsTree<HTMLDivElement>({
- fromLevel: 2,
- });
-
- const saveComment: CommentFormSubmit = useCallback(
- async (data) => {
- if (!id) throw new Error('Page id missing. Cannot save comment.');
-
- const { author, comment: commentBody, email, parentId, website } = data;
- const commentData: SendCommentInput = {
- author,
- authorEmail: email,
- authorUrl: website ?? '',
- clientMutationId: 'comment',
- commentOn: id,
- content: commentBody,
- parent: parentId,
- };
- const { comment, success } = await sendComment(commentData);
-
- if (success) {
- const successPrefix = intl.formatMessage({
- defaultMessage: 'Thanks, your comment was successfully sent.',
- description: 'PageLayout: comment form success message',
- id: 'B290Ph',
- });
- const successMessage = comment?.approved
- ? intl.formatMessage({
- defaultMessage: 'It has been approved.',
- id: 'g3+Ahv',
- description: 'PageLayout: comment approved.',
- })
- : intl.formatMessage({
- defaultMessage: 'It is now awaiting moderation.',
- id: 'Vmj5cw',
- description: 'PageLayout: comment awaiting moderation',
- });
- return {
- messages: {
- success: `${successPrefix} ${successMessage}`,
- },
- validator: () => success,
- };
- }
-
- return {
- messages: {
- error: intl.formatMessage({
- defaultMessage: 'An error occurred:',
- description: 'PageLayout: comment form error message',
- id: 'fkcTGp',
- }),
- },
- validator: () => success,
- };
- },
- [id, intl]
- );
-
- return (
- <>
- <Script
- dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
- id="schema-breadcrumb"
- type="application/ld+json"
- />
- <Breadcrumbs
- aria-label={breadcrumbsLabel}
- className={styles.breadcrumb}
- items={breadcrumb}
- />
- <PageHeader
- className={styles.header}
- intro={intro}
- meta={headerMeta}
- title={title}
- />
- {withToC ? (
- <Sidebar
- aria-label={intl.formatMessage({
- defaultMessage: 'Table of contents sidebar',
- id: 'Q+1GbT',
- description: 'PageLayout: accessible name for ToC sidebar',
- })}
- className={`${styles.sidebar} ${styles['sidebar--first']}`}
- >
- <TocWidget
- heading={<Heading level={3}>{tocTitle}</Heading>}
- tree={headingsTree}
- />
- </Sidebar>
- ) : null}
- {typeof children === 'string' ? (
- <div
- {...bodyAttributes}
- className={`${styles.body} ${bodyClassName}`}
- dangerouslySetInnerHTML={{ __html: children }}
- ref={bodyRef}
- />
- ) : (
- <div ref={bodyRef} className={`${styles.body} ${bodyClassName}`}>
- {children}
- </div>
- )}
- {footerMeta ? (
- <PageFooter className={styles.footer}>{footerMeta}</PageFooter>
- ) : null}
- <Sidebar
- aria-label={intl.formatMessage({
- defaultMessage: 'Sidebar',
- id: 'c556Qo',
- description: 'PageLayout: accessible name for the sidebar',
- })}
- className={`${styles.sidebar} ${styles['sidebar--last']}`}
- >
- {widgets}
- </Sidebar>
- {allowComments ? (
- <div className={styles.comments} id="comments">
- <section className={styles.comments__section}>
- <Heading className={styles.comments__title} level={2}>
- {commentsTitle}
- </Heading>
- {comments?.length ? (
- <CommentsList
- comments={comments}
- depth={2}
- onSubmit={saveComment}
- />
- ) : (
- <p className={styles['comments__no-comments']}>
- {intl.formatMessage({
- defaultMessage: 'No comments.',
- id: 'sBwfCy',
- description: 'PageLayout: no comments text',
- })}
- </p>
- )}
- </section>
- <section className={styles.comments__section}>
- <Heading className={styles.comments__title} level={2}>
- {commentFormSectionTitle}
- </Heading>
- <CommentForm
- aria-label={commentFormTitle}
- className={styles.comments__form}
- onSubmit={saveComment}
- />
- </section>
- </div>
- ) : null}
- </>
- );
-};
diff --git a/src/components/templates/page/page-sidebar.test.tsx b/src/components/templates/page/page-sidebar.test.tsx
new file mode 100644
index 0000000..93bb57f
--- /dev/null
+++ b/src/components/templates/page/page-sidebar.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { PageSidebar } from './page-sidebar';
+
+describe('PageSidebar', () => {
+ it('renders its contents', () => {
+ const body =
+ 'Repellendus dignissimos quos dolores sunt pariatur rem optio qui aut. Dolore optio est quam tenetur minus. Dolorem voluptas id maiores rerum velit omnis esse impedit. Unde reiciendis nisi nostrum et. Quia accusamus asperiores. Commodi est provident sequi eaque ipsa ut necessitatibus.';
+
+ render(<PageSidebar>{body}</PageSidebar>);
+
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/templates/page/page-sidebar.tsx b/src/components/templates/page/page-sidebar.tsx
new file mode 100644
index 0000000..1b5ae97
--- /dev/null
+++ b/src/components/templates/page/page-sidebar.tsx
@@ -0,0 +1,20 @@
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { Aside, type AsideProps } from '../../atoms';
+import styles from './page.module.scss';
+
+export type PageSidebarProps = AsideProps;
+
+const PageSidebarWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ PageSidebarProps
+> = ({ children, className = '', ...props }, ref) => {
+ const sidebarClass = `${styles.sidebar} ${className}`;
+
+ return (
+ <Aside {...props} className={sidebarClass} ref={ref}>
+ <div className={styles.sidebar__body}>{children}</div>
+ </Aside>
+ );
+};
+
+export const PageSidebar = forwardRef(PageSidebarWithRef);
diff --git a/src/components/templates/page/page.module.scss b/src/components/templates/page/page.module.scss
new file mode 100644
index 0000000..b521438
--- /dev/null
+++ b/src/components/templates/page/page.module.scss
@@ -0,0 +1,212 @@
+@use "../../../styles/abstracts/functions" as fun;
+@use "../../../styles/abstracts/mixins" as mix;
+@use "../../../styles/abstracts/variables" as var;
+
+%grid {
+ display: grid;
+ align-items: center;
+ grid-template-columns: var(--left-col) var(--main-col) var(--right-col);
+ column-gap: var(--col-gap);
+}
+
+.wrapper {
+ container: page / inline-size;
+}
+
+.breadcrumbs,
+.page {
+ --border-size: #{fun.convert-px(3)};
+ --col-gap: clamp(var(--spacing-md), 4vw, var(--spacing-2xl));
+ --left-col: 0;
+ --right-col: 0;
+ --main-col: minmax(0, 80ch);
+
+ @extend %grid;
+
+ grid-auto-flow: column dense;
+ align-items: baseline;
+ margin-top: var(--spacing-sm);
+}
+
+.breadcrumbs {
+ width: 100%;
+ padding: var(--spacing-xs) 0;
+
+ & > * {
+ grid-column: 2;
+ font-size: var(--font-size-sm);
+ }
+}
+
+.header {
+ display: contents;
+
+ &::before,
+ &::after {
+ align-self: stretch;
+ content: "";
+ background: var(--color-bg-secondary);
+ border: var(--border-size) solid var(--color-border-light);
+ }
+
+ &::before {
+ grid-column: 1;
+ border-left: none;
+ }
+
+ &::after {
+ grid-column: 3;
+ border-right: none;
+ }
+
+ &__body {
+ grid-column: 2;
+ display: flex;
+ flex-flow: column wrap;
+ row-gap: var(--spacing-sm);
+ }
+}
+
+.body {
+ grid-column: 2;
+ margin-top: var(--spacing-sm);
+ padding-bottom: var(--spacing-md);
+}
+
+.body > * + * {
+ margin-top: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+}
+
+.footer {
+ grid-column: 2;
+ padding: var(--spacing-sm) 0 var(--spacing-2xs);
+}
+
+.sidebar {
+ grid-column: 2;
+ margin-top: var(--spacing-md);
+
+ &__body {
+ position: sticky;
+ top: var(--spacing-xs);
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ background: transparent;
+ font-size: var(--font-size-xl);
+ }
+
+ > * + * {
+ margin-top: var(--spacing-sm);
+ }
+ }
+}
+
+:where(.footer) {
+ .btn {
+ margin-inline-end: var(--spacing-2xs);
+ }
+
+ .logo {
+ max-height: fun.convert-px(30);
+ width: auto;
+ }
+}
+
+:where(.header) {
+ .heading {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ position: relative;
+
+ &::before,
+ &::after {
+ content: "";
+ width: 100%;
+ height: var(--border-size);
+ background: radial-gradient(
+ ellipse at center,
+ var(--color-primary-light),
+ var(--color-primary-dark)
+ );
+ }
+ }
+
+ .meta {
+ font-size: var(--font-size-sm);
+ }
+
+ .intro {
+ > *:last-child {
+ margin-bottom: 0;
+ }
+ }
+}
+
+:where(.body, .footer) + .sidebar {
+ margin-bottom: var(--spacing-lg);
+}
+
+.comments {
+ @extend %grid;
+
+ grid-column: 1 / -1;
+ margin-top: var(--spacing-lg);
+ padding: 0 0 var(--spacing-lg);
+ background: var(--color-bg-secondary);
+ border-top: var(--border-size) solid var(--color-border-light);
+}
+
+:where(.comments) {
+ .section {
+ grid-column: 2;
+ }
+
+ .heading {
+ width: fit-content;
+ margin: var(--spacing-md) auto;
+ }
+
+ .form {
+ max-width: 40ch;
+ margin-inline: auto;
+ }
+}
+
+@container page (width > #{var.get-breakpoint("md")}) {
+ .breadcrumbs,
+ .page {
+ --right-col: minmax(25ch, 1fr);
+ }
+
+ :where(.page--body-last) .body {
+ padding-bottom: var(--spacing-lg);
+ }
+
+ .body + .sidebar,
+ .footer + .sidebar {
+ grid-column: 3;
+ grid-row: 2 / span 2;
+ align-self: stretch;
+ padding: 0 var(--spacing-xs) var(--spacing-md);
+ }
+}
+
+@container page (width > #{var.get-breakpoint("lg")}) {
+ .breadcrumbs,
+ .page {
+ --left-col: minmax(25ch, 1fr);
+ }
+
+ .header + .sidebar {
+ grid-column: 1;
+ align-self: stretch;
+ padding: 0 var(--spacing-xs) var(--spacing-md);
+ }
+}
diff --git a/src/components/templates/page/page.stories.tsx b/src/components/templates/page/page.stories.tsx
new file mode 100644
index 0000000..6b1058e
--- /dev/null
+++ b/src/components/templates/page/page.stories.tsx
@@ -0,0 +1,456 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Heading } from '../../atoms';
+import type { CommentData } from '../../organisms/comments-list';
+import { SharingWidget, TocWidget } from '../../organisms/widgets';
+import { Page } from './page';
+import { PageBody } from './page-body';
+import { PageComments } from './page-comments';
+import { PageFooter } from './page-footer';
+import { PageHeader } from './page-header';
+import { PageSidebar } from './page-sidebar';
+
+/**
+ * Page - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page',
+ component: Page,
+ argTypes: {},
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof Page>;
+
+const Template: ComponentStory<typeof Page> = (args) => <Page {...args} />;
+
+const comments = [
+ {
+ author: {
+ name: 'Milan0',
+ avatar: {
+ alt: 'Milan0 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
+ },
+ },
+ content: 'Fugit veniam quas qui dolor explicabo.',
+ id: 1,
+ isApproved: true,
+ publicationDate: '2023-01-23',
+ replies: [
+ {
+ author: { name: 'Haskell42' },
+ content: 'Error quas accusamus nesciunt enim quae a.',
+ id: 25,
+ isApproved: true,
+ publicationDate: '2023-02-04',
+ },
+ {
+ author: { name: 'Hanna49', website: 'https://www.armandphilippot.com' },
+ content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.',
+ id: 30,
+ isApproved: true,
+ publicationDate: '2023-03-10',
+ },
+ ],
+ },
+ {
+ author: {
+ name: 'Corrine9',
+ avatar: {
+ alt: 'Corrine9 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
+ },
+ },
+ content:
+ 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
+ id: 2,
+ isApproved: true,
+ publicationDate: '2023-04-20',
+ },
+ {
+ author: { name: 'Presley12' },
+ content:
+ 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
+ id: 3,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ replies: [
+ {
+ author: {
+ name: 'Ana_Haley33',
+ avatar: {
+ alt: 'Ana_Haley33 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg',
+ },
+ },
+ content: 'Ab ea et fugit autem.',
+ id: 17,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ },
+ {
+ author: { name: 'Santos.Harris17' },
+ content:
+ 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.',
+ id: 18,
+ isApproved: false,
+ publicationDate: '2023-05-02',
+ },
+ ],
+ },
+ {
+ author: { name: 'Julius.Borer' },
+ content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
+ id: 4,
+ isApproved: true,
+ publicationDate: '2023-06-15',
+ },
+ {
+ author: { name: 'Geo87' },
+ content:
+ 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
+ id: 5,
+ isApproved: false,
+ publicationDate: '2023-06-16',
+ },
+ {
+ author: { name: 'Kurt.Keeling' },
+ content: 'Eligendi repellat officiis amet.',
+ id: 6,
+ isApproved: true,
+ publicationDate: '2023-06-17',
+ },
+] satisfies CommentData[];
+
+/**
+ * Page Stories - HeaderBody
+ */
+export const HeaderBody = Template.bind({});
+HeaderBody.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - BreadcrumbsHeaderBody
+ */
+export const BreadcrumbsHeaderBody = Template.bind({});
+BreadcrumbsHeaderBody.args = {
+ breadcrumbs: [
+ { id: 'home', name: 'Home', url: '#home' },
+ { id: 'blog', name: 'Blog', url: '#blog' },
+ ],
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyToc
+ */
+export const HeaderBodyToc = Template.bind({});
+HeaderBodyToc.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={2}>Table of Contents</Heading>}
+ tree={[
+ {
+ children: [],
+ depth: 2,
+ id: 'sint-debitis',
+ label: 'Sint debitis blanditiis',
+ },
+ {
+ children: [],
+ depth: 2,
+ id: 'non-nisi',
+ label: 'Non nisi similique',
+ },
+ ]}
+ />
+ </PageSidebar>
+ <PageBody>
+ <Heading id="sint-debitis" level={2}>
+ Sint debitis blanditiis
+ </Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading id="non-nisi" level={2}>
+ Non nisi similique
+ </Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyTocSidebar
+ */
+export const HeaderBodyTocSidebar = Template.bind({});
+HeaderBodyTocSidebar.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={2}>Table of Contents</Heading>}
+ tree={[
+ {
+ children: [],
+ depth: 2,
+ id: 'sint-debitis',
+ label: 'Sint debitis blanditiis',
+ },
+ {
+ children: [],
+ depth: 2,
+ id: 'non-nisi',
+ label: 'Non nisi similique',
+ },
+ ]}
+ />
+ </PageSidebar>
+ <PageBody>
+ <Heading id="sint-debitis" level={2}>
+ Sint debitis blanditiis
+ </Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading id="non-nisi" level={2}>
+ Non nisi similique
+ </Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ <PageSidebar>
+ <SharingWidget
+ data={{
+ excerpt:
+ 'Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit.',
+ title: 'The page title',
+ url: '#page',
+ }}
+ heading={<Heading level={2}>Share</Heading>}
+ media={['diaspora', 'email', 'facebook']}
+ />
+ </PageSidebar>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyFooter
+ */
+export const HeaderBodyFooter = Template.bind({});
+HeaderBodyFooter.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ <PageFooter
+ readMoreAbout={[
+ { id: 1, name: 'Topic 1', url: '#topic1' },
+ { id: 2, name: 'Topic 2', url: '#topic2' },
+ ]}
+ />
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyComments
+ */
+export const HeaderBodyComments = Template.bind({});
+HeaderBodyComments.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ <PageComments comments={comments} pageId={1} />
+ </>
+ ),
+};
diff --git a/src/components/templates/page/page.test.tsx b/src/components/templates/page/page.test.tsx
new file mode 100644
index 0000000..21c5a86
--- /dev/null
+++ b/src/components/templates/page/page.test.tsx
@@ -0,0 +1,49 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import type { BreadcrumbsItem } from '../../organisms';
+import { Page } from './page';
+import { PageBody } from './page-body';
+
+describe('Page', () => {
+ it('renders its children', () => {
+ const body =
+ 'Consequatur deleniti eligendi quidem sint et nobis ut qui. Dolores modi eos. Cupiditate aliquid sunt consequatur voluptatem laudantium.';
+
+ render(
+ <Page>
+ <PageBody>{body}</PageBody>
+ </Page>
+ );
+
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+
+ it('can render the breadcrumbs', () => {
+ const body =
+ 'Consequatur deleniti eligendi quidem sint et nobis ut qui. Dolores modi eos. Cupiditate aliquid sunt consequatur voluptatem laudantium.';
+ const breadcrumbs = [
+ { id: 'home', name: 'Home', url: '#home' },
+ { id: 'blog', name: 'Blog', url: '#blog' },
+ ] satisfies BreadcrumbsItem[];
+
+ render(
+ <Page breadcrumbs={breadcrumbs}>
+ <PageBody>{body}</PageBody>
+ </Page>
+ );
+
+ expect(rtlScreen.getByRole('navigation')).toHaveAccessibleName(
+ 'Breadcrumbs'
+ );
+ expect(rtlScreen.getAllByRole('listitem')).toHaveLength(breadcrumbs.length);
+ });
+
+ it('can have a class modifier based on a prop', () => {
+ const body =
+ 'Consequatur deleniti eligendi quidem sint et nobis ut qui. Dolores modi eos. Cupiditate aliquid sunt consequatur voluptatem laudantium.';
+
+ render(<Page isBodyLastChild>{body}</Page>);
+
+ expect(rtlScreen.getByText(body)).toHaveClass('page--body-last');
+ });
+});
diff --git a/src/components/templates/page/page.tsx b/src/components/templates/page/page.tsx
new file mode 100644
index 0000000..f5f3ea5
--- /dev/null
+++ b/src/components/templates/page/page.tsx
@@ -0,0 +1,56 @@
+import {
+ type ForwardRefRenderFunction,
+ forwardRef,
+ type HTMLAttributes,
+} from 'react';
+import { useIntl } from 'react-intl';
+import { Article } from '../../atoms';
+import { Breadcrumbs, type BreadcrumbsItem } from '../../organisms/nav';
+import styles from './page.module.scss';
+
+export type PageProps = HTMLAttributes<HTMLDivElement> & {
+ /**
+ * The breadcrumbs items.
+ */
+ breadcrumbs?: BreadcrumbsItem[];
+ /**
+ * Add an extra padding to the body when there are no footer/comments.
+ *
+ * Note: this should be refactored when `:has()` pseudo-class will have a
+ * better support.
+ *
+ * @default false
+ */
+ isBodyLastChild?: boolean;
+};
+
+const PageWithRef: ForwardRefRenderFunction<HTMLDivElement, PageProps> = (
+ { breadcrumbs, children, className = '', isBodyLastChild = false, ...props },
+ ref
+) => {
+ const wrapperClass = `${styles.wrapper} ${className}`;
+ const pageClass = `${styles.page} ${
+ styles[isBodyLastChild ? 'page--body-last' : '']
+ }`;
+ const intl = useIntl();
+ const breadcrumbsLabel = intl.formatMessage({
+ defaultMessage: 'Breadcrumbs',
+ description: 'Page: an accessible name for the breadcrumb nav.',
+ id: '/TTRRX',
+ });
+
+ return (
+ <div {...props} className={wrapperClass} ref={ref}>
+ {breadcrumbs ? (
+ <Breadcrumbs
+ aria-label={breadcrumbsLabel}
+ className={styles.breadcrumbs}
+ items={breadcrumbs}
+ />
+ ) : null}
+ <Article className={pageClass}>{children}</Article>
+ </div>
+ );
+};
+
+export const Page = forwardRef(PageWithRef);
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 2091057..7b59fdb 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -15,14 +15,18 @@
"defaultMessage": "Blog: development, open source - {websiteName}",
"description": "BlogPage: SEO - Page title"
},
- "+dJU3e": {
- "defaultMessage": "Comments",
- "description": "PageLayout: comments title"
- },
"/42Z0z": {
"defaultMessage": "Related topics",
"description": "ThematicPage: related topics list widget title"
},
+ "/EfcyW": {
+ "defaultMessage": "It is now awaiting moderation.",
+ "description": "PageComments: comment awaiting moderation"
+ },
+ "/TTRRX": {
+ "defaultMessage": "Breadcrumbs",
+ "description": "Page: an accessible name for the breadcrumb nav."
+ },
"/q5csZ": {
"defaultMessage": "Animations:",
"description": "MotionToggle: reduce motion label"
@@ -31,6 +35,10 @@
"defaultMessage": "Related thematics",
"description": "TopicPage: related thematics list widget title"
},
+ "/unaGZ": {
+ "defaultMessage": "Written by:",
+ "description": "PageHeader: author meta label"
+ },
"0f7fty": {
"defaultMessage": "Share on Diaspora",
"description": "SharingWidget: Diaspora sharing link"
@@ -95,18 +103,10 @@
"defaultMessage": "Page not found.",
"description": "404Page: SEO - Meta description"
},
- "4QbTDq": {
- "defaultMessage": "Published on:",
- "description": "Page: publication date label"
- },
"4iYISO": {
"defaultMessage": "Loading the requested article...",
"description": "ArticlePage: loading article message"
},
- "50xc4o": {
- "defaultMessage": "Read more articles about:",
- "description": "ArticlePage: footer topics list label"
- },
"52H2HA": {
"defaultMessage": "{website} logo",
"description": "Layout: logo title"
@@ -159,6 +159,10 @@
"defaultMessage": "{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}",
"description": "PostsList: loaded articles progress"
},
+ "9jh0r2": {
+ "defaultMessage": "Website:",
+ "description": "PageHeader: website meta label"
+ },
"9sGNKq": {
"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": "Error404Page: page body"
@@ -191,10 +195,6 @@
"defaultMessage": "Reading time:",
"description": "PostPreviewMeta: reading time label"
},
- "B290Ph": {
- "defaultMessage": "Thanks, your comment was successfully sent.",
- "description": "PageLayout: comment form success message"
- },
"B9OCyV": {
"defaultMessage": "Others formats",
"description": "CVPage: cv preview widget title"
@@ -223,10 +223,6 @@
"defaultMessage": "Query must be longer than one character.",
"description": "404Page: invalid query message"
},
- "CvOqoh": {
- "defaultMessage": "Thematics:",
- "description": "ArticlePage: thematics meta label"
- },
"D8vB38": {
"defaultMessage": "Blog",
"description": "Layout: main nav - blog link"
@@ -243,10 +239,6 @@
"defaultMessage": "Blog",
"description": "Breadcrumb: blog label"
},
- "Ez8Qim": {
- "defaultMessage": "Updated on:",
- "description": "Page: update date label"
- },
"FCpPCm": {
"defaultMessage": "Comments:",
"description": "PostPreviewMeta: comments label"
@@ -263,9 +255,9 @@
"defaultMessage": "Topics",
"description": "Error404Page: topics list widget title"
},
- "Gw7X3x": {
- "defaultMessage": "Reading time:",
- "description": "ArticlePage: reading time label"
+ "H4pKJP": {
+ "defaultMessage": "{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}",
+ "description": "PageComments: the section title of the comments list"
},
"HKKkQk": {
"defaultMessage": "Share",
@@ -283,6 +275,10 @@
"defaultMessage": "Thematics",
"description": "BlogPage: thematics list widget title"
},
+ "I6vhfk": {
+ "defaultMessage": "Read more posts about:",
+ "description": "PageFooter: the topics list label"
+ },
"IVczxR": {
"defaultMessage": "Go to page {number}",
"description": "BlogPage: page number label"
@@ -327,10 +323,6 @@
"defaultMessage": "All posts in {thematicName}",
"description": "ThematicPage: posts list heading"
},
- "MJbZfX": {
- "defaultMessage": "Written by:",
- "description": "ArticlePage: author label"
- },
"Mq+O6q": {
"defaultMessage": "Search",
"description": "Layout: search modal title in navbar"
@@ -343,6 +335,10 @@
"defaultMessage": "Topics",
"description": "SearchPage: topics list widget title"
},
+ "NNDqRg": {
+ "defaultMessage": "{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}",
+ "description": "PageHeader: rounded minutes count"
+ },
"NfAn9N": {
"defaultMessage": "{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>",
"description": "PostPreviewMeta: comments count"
@@ -355,6 +351,14 @@
"defaultMessage": "Published on:",
"description": "ApprovedComment: publication date label"
},
+ "ODwkBI": {
+ "defaultMessage": "{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}",
+ "description": "PageHeader: thematics label"
+ },
+ "OHvb01": {
+ "defaultMessage": "Back to top",
+ "description": "SiteFooter: an accessible name for the back to top button"
+ },
"OL0Yzx": {
"defaultMessage": "Publish",
"description": "CommentForm: submit button"
@@ -367,10 +371,6 @@
"defaultMessage": "Share by Email",
"description": "SharingWidget: Email sharing link"
},
- "OevMeU": {
- "defaultMessage": "{minutesCount} minutes {secondsCount} seconds",
- "description": "useReadingTime: minutes + seconds count"
- },
"PBdVsm": {
"defaultMessage": "{starsCount, plural, =0 {No stars} one {# star} other {# stars}}",
"description": "ProjectOverview: stars count"
@@ -379,10 +379,6 @@
"defaultMessage": "{websiteName} | Front-end developer: WordPress/React",
"description": "HomePage: SEO - Page title"
},
- "Q+1GbT": {
- "defaultMessage": "Table of contents sidebar",
- "description": "PageLayout: accessible name for ToC sidebar"
- },
"Q3oEQn": {
"defaultMessage": "LinkedIn profile",
"description": "ContactPage: LinkedIn profile link"
@@ -411,10 +407,6 @@
"defaultMessage": "CV",
"description": "Layout: main nav - cv link"
},
- "RvGb2c": {
- "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}",
- "description": "Page: posts count meta"
- },
"RwI3B9": {
"defaultMessage": "Loading the repository popularity...",
"description": "ProjectsPage: loading repository popularity"
@@ -431,6 +423,10 @@
"defaultMessage": "An error occurred:",
"description": "Contact: error message"
},
+ "UgJwSU": {
+ "defaultMessage": "It has been approved.",
+ "description": "PageComments: comment approved."
+ },
"VkAnvv": {
"defaultMessage": "Send",
"description": "ContactForm: send button"
@@ -439,10 +435,6 @@
"defaultMessage": "Query must be longer than one character.",
"description": "NoResults: invalid query message"
},
- "Vmj5cw": {
- "defaultMessage": "It is now awaiting moderation.",
- "description": "PageLayout: comment awaiting moderation"
- },
"WDwNDl": {
"defaultMessage": "Search",
"description": "SearchPage: SEO - Page title"
@@ -467,6 +459,10 @@
"defaultMessage": "Open search",
"description": "Layout: search button label in navbar"
},
+ "Y7XdNp": {
+ "defaultMessage": "Leave a comment",
+ "description": "PageComments: the section title of the comment form"
+ },
"YV//MH": {
"defaultMessage": "No results found.",
"description": "SearchPage: no results"
@@ -487,14 +483,26 @@
"defaultMessage": "Search results for {query}",
"description": "SearchPage: SEO - Page title"
},
+ "ZcFroC": {
+ "defaultMessage": "Thanks, your comment was successfully sent.",
+ "description": "PageComments: comment form success message"
+ },
"ZmRh0V": {
"defaultMessage": "Updated on:",
"description": "PostPreviewMeta: update date label"
},
+ "a6DzIj": {
+ "defaultMessage": "Total:",
+ "description": "PageHeader: total meta label"
+ },
"aBQYbE": {
"defaultMessage": "{topicsCount, plural, =0 {Topics:} one {Topic:} other {Topics:}}",
"description": "PostPreviewMeta: topics label"
},
+ "bAXtMT": {
+ "defaultMessage": "{postsCount, plural, =0 {No posts} one {# post} other {# posts}}",
+ "description": "PageHeader: total meta value"
+ },
"bPv0VG": {
"defaultMessage": "Contact form",
"description": "Contact: form accessible name"
@@ -511,10 +519,6 @@
"defaultMessage": "Leave a reply to {name}",
"description": "CommentsList: comment form title"
},
- "c556Qo": {
- "defaultMessage": "Sidebar",
- "description": "PageLayout: accessible name for the sidebar"
- },
"cIAOyy": {
"defaultMessage": "Popularity:",
"description": "ProjectOverview: popularity label"
@@ -539,18 +543,10 @@
"defaultMessage": "Go to previous page, page {number}",
"description": "BlogPage: previous page label"
},
- "fkcTGp": {
- "defaultMessage": "An error occurred:",
- "description": "PageLayout: comment form error message"
- },
"ftXN+0": {
"defaultMessage": "Code blocks:",
"description": "PrismThemeToggle: theme label"
},
- "g3+Ahv": {
- "defaultMessage": "It has been approved.",
- "description": "PageLayout: comment approved."
- },
"h3J0a+": {
"defaultMessage": "Settings form",
"description": "Layout: an accessible name for the settings form in navbar"
@@ -567,17 +563,13 @@
"defaultMessage": "Shaarli",
"description": "HomePage: link to Shaarli"
},
- "i7Wq3G": {
- "defaultMessage": "{count} seconds",
- "description": "useReadingTime: seconds count"
- },
"iG5SHf": {
"defaultMessage": "{postTitle} cover",
"description": "PostPreview: an accessible name for the figure wrapping the cover"
},
- "iv3Ex1": {
- "defaultMessage": "{postsCount, plural, =0 {No articles} one {# article} other {# articles}}",
- "description": "ThematicPage: posts count meta"
+ "iTLvLX": {
+ "defaultMessage": "CC BY SA",
+ "description": "SiteFooter: the license name"
},
"j5k9Fe": {
"defaultMessage": "Home",
@@ -587,29 +579,17 @@
"defaultMessage": "Linux",
"description": "HomePage: link to Linux thematic"
},
- "kNBXyK": {
- "defaultMessage": "Total:",
- "description": "Page: total label"
- },
- "kzIYoQ": {
- "defaultMessage": "Leave a comment",
- "description": "PageLayout: comment form title"
- },
- "l+Jcf6": {
- "defaultMessage": "Comment form",
- "description": "PageLayout: comment form accessible name"
- },
- "lHkta9": {
- "defaultMessage": "Total:",
- "description": "ThematicPage: total label"
+ "jJm8wd": {
+ "defaultMessage": "Reading time:",
+ "description": "PageHeader: reading time label"
},
"lKhTGM": {
"defaultMessage": "Use Ctrl+c to copy",
"description": "usePrism: copy button error text"
},
- "m6a3BD": {
- "defaultMessage": "Breadcrumb",
- "description": "PageLayout: an accessible name for the breadcrumb nav."
+ "lsmD4c": {
+ "defaultMessage": "Legal notice",
+ "description": "SiteFooter: Legal notice link label"
},
"mDKiaN": {
"defaultMessage": "Open settings",
@@ -635,6 +615,10 @@
"defaultMessage": "Legal notice",
"description": "Layout: Legal notice label"
},
+ "o+wCJz": {
+ "defaultMessage": "Comment form",
+ "description": "PageComments: an accessible name for the comment form"
+ },
"o3WSz5": {
"defaultMessage": "Settings",
"description": "Layout: settings modal title in navbar"
@@ -655,6 +639,10 @@
"defaultMessage": "Published on:",
"description": "HomePage: publication date label"
},
+ "pUBhKy": {
+ "defaultMessage": "Published on:",
+ "description": "PageHeader: publication date label"
+ },
"pWKyyR": {
"defaultMessage": "Off",
"description": "MotionToggle: deactivate reduce motion label"
@@ -667,18 +655,10 @@
"defaultMessage": "Projects",
"description": "Layout: main nav - projects link"
},
- "s1i43J": {
- "defaultMessage": "{minutesCount} minutes",
- "description": "useReadingTime: rounded minutes count"
- },
"s8/tyz": {
"defaultMessage": "Object:",
"description": "ContactForm: object label"
},
- "sBwfCy": {
- "defaultMessage": "No comments.",
- "description": "PageLayout: no comments text"
- },
"sECHDg": {
"defaultMessage": "Gitlab profile",
"description": "ProjectsPage: Gitlab profile link"
@@ -687,6 +667,10 @@
"defaultMessage": "Contact me",
"description": "HomePage: contact button text"
},
+ "sR5hah": {
+ "defaultMessage": "Updated on:",
+ "description": "PageHeader: update date label"
+ },
"suXOBu": {
"defaultMessage": "Theme:",
"description": "ThemeToggle: theme label"
@@ -723,6 +707,10 @@
"defaultMessage": "Would you like to try a new search?",
"description": "SearchPage: try a new search message"
},
+ "w+BpPg": {
+ "defaultMessage": "No comments yet. <link>Be the first!</link>",
+ "description": "PageComments: no comments text"
+ },
"w4B5PA": {
"defaultMessage": "Email:",
"description": "ContactForm: email label"
@@ -754,9 +742,5 @@
"zbzlb1": {
"defaultMessage": "Page {number}",
"description": "BlogPage: page number"
- },
- "zoifQd": {
- "defaultMessage": "Official website:",
- "description": "TopicPage: official website label"
}
}
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index d6bdef1..1ca2efd 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -15,14 +15,18 @@
"defaultMessage": "Blog : développement, libre et open-source - {websiteName}",
"description": "BlogPage: SEO - Page title"
},
- "+dJU3e": {
- "defaultMessage": "Commentaires",
- "description": "PageLayout: comments title"
- },
"/42Z0z": {
"defaultMessage": "Sujets liés",
"description": "ThematicPage: related topics list widget title"
},
+ "/EfcyW": {
+ "defaultMessage": "Il est maintenant en attente de modération.",
+ "description": "PageComments: comment awaiting moderation"
+ },
+ "/TTRRX": {
+ "defaultMessage": "Fil d’Ariane",
+ "description": "Page: an accessible name for the breadcrumb nav."
+ },
"/q5csZ": {
"defaultMessage": "Animations :",
"description": "MotionToggle: reduce motion label"
@@ -31,6 +35,10 @@
"defaultMessage": "Thématiques liées",
"description": "TopicPage: related thematics list widget title"
},
+ "/unaGZ": {
+ "defaultMessage": "Écrit par :",
+ "description": "PageHeader: author meta label"
+ },
"0f7fty": {
"defaultMessage": "Partager sur Diaspora",
"description": "SharingWidget: Diaspora sharing link"
@@ -95,18 +103,10 @@
"defaultMessage": "Page non trouvée.",
"description": "404Page: SEO - Meta description"
},
- "4QbTDq": {
- "defaultMessage": "Publié le :",
- "description": "Page: publication date label"
- },
"4iYISO": {
"defaultMessage": "Chargement de l’article demandé…",
"description": "ArticlePage: loading article message"
},
- "50xc4o": {
- "defaultMessage": "Lire plus d’articles à propos de :",
- "description": "ArticlePage: footer topics list label"
- },
"52H2HA": {
"defaultMessage": "Logo du site d’{website}",
"description": "Layout: logo title"
@@ -159,6 +159,10 @@
"defaultMessage": "{articlesCount, plural, =0 {# article chargé} one {# article chargé} other {# articles chargés}} sur un total de {total}",
"description": "PostsList: loaded articles progress"
},
+ "9jh0r2": {
+ "defaultMessage": "Site officiel :",
+ "description": "PageHeader: website meta label"
+ },
"9sGNKq": {
"defaultMessage": "Désolé, il semble que la page demandée n’existe pas. Si vous pensez que le chemin devrait exister, n’hésitez pas à <link>me contacter</link> avec les informations nécessaires pour que je puisse corriger le problème.",
"description": "Error404Page: page body"
@@ -191,10 +195,6 @@
"defaultMessage": "Temps de lecture :",
"description": "PostPreviewMeta: reading time label"
},
- "B290Ph": {
- "defaultMessage": "Merci, votre commentaire a été envoyé avec succès.",
- "description": "PageLayout: comment form success message"
- },
"B9OCyV": {
"defaultMessage": "Autres formats",
"description": "CVPage: cv preview widget title"
@@ -223,10 +223,6 @@
"defaultMessage": "Les mots-clés doivent être plus longs qu'un caractère.",
"description": "404Page: invalid query message"
},
- "CvOqoh": {
- "defaultMessage": "Thématiques :",
- "description": "ArticlePage: thematics meta label"
- },
"D8vB38": {
"defaultMessage": "Blog",
"description": "Layout: main nav - blog link"
@@ -243,10 +239,6 @@
"defaultMessage": "Blog",
"description": "Breadcrumb: blog label"
},
- "Ez8Qim": {
- "defaultMessage": "Mis à jour le :",
- "description": "Page: update date label"
- },
"FCpPCm": {
"defaultMessage": "Commentaires :",
"description": "PostPreviewMeta: comments label"
@@ -263,9 +255,9 @@
"defaultMessage": "Sujets",
"description": "Error404Page: topics list widget title"
},
- "Gw7X3x": {
- "defaultMessage": "Temps de lecture :",
- "description": "ArticlePage: reading time label"
+ "H4pKJP": {
+ "defaultMessage": "{commentsCount, plural, =0 {0 commentaire} one {# commentaire} other {# commentaires}}",
+ "description": "PageComments: the section title of the comments list"
},
"HKKkQk": {
"defaultMessage": "Partager",
@@ -283,6 +275,10 @@
"defaultMessage": "Thématiques",
"description": "BlogPage: thematics list widget title"
},
+ "I6vhfk": {
+ "defaultMessage": "Lire plus d’articles à propos de :",
+ "description": "PageFooter: the topics list label"
+ },
"IVczxR": {
"defaultMessage": "Aller à la page {number}",
"description": "BlogPage: page number label"
@@ -327,10 +323,6 @@
"defaultMessage": "Tous les articles dans {thematicName}",
"description": "ThematicPage: posts list heading"
},
- "MJbZfX": {
- "defaultMessage": "Écrit par :",
- "description": "ArticlePage: author label"
- },
"Mq+O6q": {
"defaultMessage": "Recherche",
"description": "Layout: search modal title in navbar"
@@ -343,6 +335,10 @@
"defaultMessage": "Sujets",
"description": "SearchPage: topics list widget title"
},
+ "NNDqRg": {
+ "defaultMessage": "{minutesCount, plural, =0 {Moins d’une minute} one {# minute} other {# minutes}}",
+ "description": "PageHeader: rounded minutes count"
+ },
"NfAn9N": {
"defaultMessage": "{commentsCount, plural, =0 {0 commentaire} one {# commentaire} other {# commentaires}}<a11y> à propos de {title}</a11y>",
"description": "PostPreviewMeta: comments count"
@@ -355,6 +351,10 @@
"defaultMessage": "Publié le :",
"description": "ApprovedComment: publication date label"
},
+ "ODwkBI": {
+ "defaultMessage": "{thematicsCount, plural, =0 {Thématiques :} one {Thématique :} other {Thématiques :}}",
+ "description": "PageHeader: thematics label"
+ },
"OL0Yzx": {
"defaultMessage": "Publier",
"description": "CommentForm: submit button"
@@ -367,10 +367,6 @@
"defaultMessage": "Partager par email",
"description": "SharingWidget: Email sharing link"
},
- "OevMeU": {
- "defaultMessage": "{minutesCount} minutes {secondsCount} secondes",
- "description": "useReadingTime: minutes + seconds count"
- },
"PBdVsm": {
"defaultMessage": "{starsCount, plural, =0 {0 étoile} one {# étoile} other {# étoiles}}",
"description": "ProjectOverview: stars count"
@@ -379,10 +375,6 @@
"defaultMessage": "{websiteName} | Intégrateur web - Développeur WordPress / React",
"description": "HomePage: SEO - Page title"
},
- "Q+1GbT": {
- "defaultMessage": "Barre latérale de la table des matières",
- "description": "PageLayout: accessible name for ToC sidebar"
- },
"Q3oEQn": {
"defaultMessage": "Profil LinkedIn",
"description": "ContactPage: LinkedIn profile link"
@@ -411,10 +403,6 @@
"defaultMessage": "CV",
"description": "Layout: main nav - cv link"
},
- "RvGb2c": {
- "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}",
- "description": "Page: posts count meta"
- },
"RwI3B9": {
"defaultMessage": "Chargement de la popularité du dépôt…",
"description": "ProjectsPage: loading repository popularity"
@@ -431,6 +419,10 @@
"defaultMessage": "Une erreur est survenue :",
"description": "Contact: error message"
},
+ "UgJwSU": {
+ "defaultMessage": "Il a été approuvé.",
+ "description": "PageComments: comment approved."
+ },
"VkAnvv": {
"defaultMessage": "Envoyer",
"description": "ContactForm: send button"
@@ -439,10 +431,6 @@
"defaultMessage": "Les mots-clés doivent être plus longs qu'un caractère.",
"description": "NoResults: invalid query message"
},
- "Vmj5cw": {
- "defaultMessage": "Il est maintenant en attente de modération.",
- "description": "PageLayout: comment awaiting moderation"
- },
"WDwNDl": {
"defaultMessage": "Recherche",
"description": "SearchPage: SEO - Page title"
@@ -467,6 +455,10 @@
"defaultMessage": "Ouvrir la recherche",
"description": "Layout: search button label in navbar"
},
+ "Y7XdNp": {
+ "defaultMessage": "Laisser un commentaire",
+ "description": "PageComments: the section title of the comment form"
+ },
"YV//MH": {
"defaultMessage": "Aucun résultat.",
"description": "SearchPage: no results"
@@ -487,14 +479,26 @@
"defaultMessage": "Résultats de la recherche pour {query}",
"description": "SearchPage: SEO - Page title"
},
+ "ZcFroC": {
+ "defaultMessage": "Merci, votre commentaire a été envoyé avec succès.",
+ "description": "PageComments: comment form success message"
+ },
"ZmRh0V": {
"defaultMessage": "Mis à jour le :",
"description": "PostPreviewMeta: update date label"
},
+ "a6DzIj": {
+ "defaultMessage": "Total :",
+ "description": "PageHeader: total meta label"
+ },
"aBQYbE": {
"defaultMessage": "{topicsCount, plural, =0 {Sujets :} one {Sujet :} other {Sujets :}}",
"description": "PostPreviewMeta: topics label"
},
+ "bAXtMT": {
+ "defaultMessage": "{postsCount, plural, =0 {Aucun article} one {# article} other {# articles}}",
+ "description": "PageHeader: total meta value"
+ },
"bPv0VG": {
"defaultMessage": "Formulaire de contact",
"description": "Contact: form accessible name"
@@ -511,10 +515,6 @@
"defaultMessage": "Laisser une réponse à {name}",
"description": "CommentsList: comment form title"
},
- "c556Qo": {
- "defaultMessage": "Barre latérale",
- "description": "PageLayout: accessible name for the sidebar"
- },
"cIAOyy": {
"defaultMessage": "Popularité :",
"description": "ProjectOverview: popularity label"
@@ -539,18 +539,10 @@
"defaultMessage": "Aller à la page précédente, page {number}",
"description": "BlogPage: previous page label"
},
- "fkcTGp": {
- "defaultMessage": "Une erreur est survenue :",
- "description": "PageLayout: comment form error message"
- },
"ftXN+0": {
"defaultMessage": "Blocs de code :",
"description": "PrismThemeToggle: theme label"
},
- "g3+Ahv": {
- "defaultMessage": "Il a été approuvé.",
- "description": "PageLayout: comment approved."
- },
"h3J0a+": {
"defaultMessage": "Formulaire des réglages",
"description": "Layout: an accessible name for the settings form in navbar"
@@ -567,18 +559,10 @@
"defaultMessage": "Shaarli",
"description": "HomePage: link to Shaarli"
},
- "i7Wq3G": {
- "defaultMessage": "{count} secondes",
- "description": "useReadingTime: seconds count"
- },
"iG5SHf": {
"defaultMessage": "Illustration de {postTitle}",
"description": "PostPreview: an accessible name for the figure wrapping the cover"
},
- "iv3Ex1": {
- "defaultMessage": "{postsCount, plural, =0 {0 article} one {# article} other {# articles}}",
- "description": "ThematicPage: posts count meta"
- },
"j5k9Fe": {
"defaultMessage": "Accueil",
"description": "Breadcrumb: home label"
@@ -587,30 +571,14 @@
"defaultMessage": "Linux",
"description": "HomePage: link to Linux thematic"
},
- "kNBXyK": {
- "defaultMessage": "Total :",
- "description": "Page: total label"
- },
- "kzIYoQ": {
- "defaultMessage": "Laisser un commentaire",
- "description": "PageLayout: comment form title"
- },
- "l+Jcf6": {
- "defaultMessage": "Formulaire des commentaires",
- "description": "PageLayout: comment form accessible name"
- },
- "lHkta9": {
- "defaultMessage": "Total :",
- "description": "ThematicPage: total label"
+ "jJm8wd": {
+ "defaultMessage": "Temps de lecture :",
+ "description": "PageHeader: reading time label"
},
"lKhTGM": {
"defaultMessage": "Utilisez Ctrl+c pour copier",
"description": "usePrism: copy button error text"
},
- "m6a3BD": {
- "defaultMessage": "Fil d’Ariane",
- "description": "PageLayout: an accessible name for the breadcrumb nav."
- },
"mDKiaN": {
"defaultMessage": "Ouvrir les réglages",
"description": "Layout: settings button label in navbar"
@@ -635,6 +603,10 @@
"defaultMessage": "Mentions légales",
"description": "Layout: Legal notice label"
},
+ "o+wCJz": {
+ "defaultMessage": "Formulaire des commentaires",
+ "description": "PageComments: an accessible name for the comment form"
+ },
"o3WSz5": {
"defaultMessage": "Réglages",
"description": "Layout: settings modal title in navbar"
@@ -655,6 +627,10 @@
"defaultMessage": "Publié le :",
"description": "HomePage: publication date label"
},
+ "pUBhKy": {
+ "defaultMessage": "Publié le :",
+ "description": "PageHeader: publication date label"
+ },
"pWKyyR": {
"defaultMessage": "Arrêt",
"description": "MotionToggle: deactivate reduce motion label"
@@ -667,18 +643,10 @@
"defaultMessage": "Projets",
"description": "Layout: main nav - projects link"
},
- "s1i43J": {
- "defaultMessage": "{minutesCount} minutes",
- "description": "useReadingTime: rounded minutes count"
- },
"s8/tyz": {
"defaultMessage": "Sujet :",
"description": "ContactForm: object label"
},
- "sBwfCy": {
- "defaultMessage": "Aucun commentaire.",
- "description": "PageLayout: no comments text"
- },
"sECHDg": {
"defaultMessage": "Profil Gitlab",
"description": "ProjectsPage: Gitlab profile link"
@@ -687,6 +655,10 @@
"defaultMessage": "Me contacter",
"description": "HomePage: contact button text"
},
+ "sR5hah": {
+ "defaultMessage": "Mis à jour le :",
+ "description": "PageHeader: update date label"
+ },
"suXOBu": {
"defaultMessage": "Thème :",
"description": "ThemeToggle: theme label"
@@ -723,6 +695,10 @@
"defaultMessage": "Souhaitez-vous essayer une nouvelle recherche ?",
"description": "SearchPage: try a new search message"
},
+ "w+BpPg": {
+ "defaultMessage": "Aucun commentaire pour le moment. <link>Soyez le premier !</link>",
+ "description": "PageComments: no comments text"
+ },
"w4B5PA": {
"defaultMessage": "E-mail :",
"description": "ContactForm: email label"
@@ -754,9 +730,5 @@
"zbzlb1": {
"defaultMessage": "Page {number}",
"description": "BlogPage: page number"
- },
- "zoifQd": {
- "defaultMessage": "Site officiel :",
- "description": "TopicPage: official website label"
}
}
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 75e2205..d6785b6 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -2,6 +2,7 @@
import type { GetStaticProps } from 'next';
import Head from 'next/head';
import { useRouter } from 'next/router';
+import Script from 'next/script';
import { useCallback, type ReactNode } from 'react';
import { useIntl } from 'react-intl';
import {
@@ -9,7 +10,10 @@ import {
Heading,
Link,
LinksWidget,
- PageLayout,
+ Page,
+ PageBody,
+ PageHeader,
+ PageSidebar,
SearchForm,
type SearchFormSubmit,
} from '../components';
@@ -111,45 +115,20 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
);
return (
- <>
+ <Page breadcrumbs={breadcrumbItems}>
<Head>
<title>{pageTitle}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
<meta name="description" content={pageDescription} />
</Head>
- <PageLayout
- title={title}
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- widgets={[
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="thematics-list"
- />,
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="topics-list"
- />,
- ]}
- >
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader heading={title} />
+ <PageBody>
{body}
<p>
{intl.formatMessage({
@@ -159,13 +138,36 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
})}
</p>
<SearchForm isLabelHidden onSubmit={searchSubmitHandler} />
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {thematicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ thematicsList.map((thematic) =>
+ getPageLinkFromRawData(thematic, 'thematic')
+ )
+ )}
+ />
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {topicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
+ )}
+ />
+ </PageSidebar>
+ </Page>
);
};
-Error404Page.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+Error404Page.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<Error404PageProps> = async ({
locale,
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 0cba7a6..224b1c5 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -2,24 +2,23 @@
import type { ParsedUrlQuery } from 'querystring';
import type { GetStaticPaths, GetStaticProps } from 'next';
import Head from 'next/head';
-import NextImage from 'next/image';
import { useRouter } from 'next/router';
import Script from 'next/script';
-import type { HTMLAttributes } from 'react';
import { useIntl } from 'react-intl';
import type { Comment as CommentSchema, WithContext } from 'schema-dts';
import {
- ButtonLink,
getLayout,
- Link,
- PageLayout,
SharingWidget,
Spinner,
- Time,
type CommentData,
Heading,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageBody,
+ PageFooter,
+ PageComments,
+ PageSidebar,
+ TocWidget,
} from '../../components';
import {
getAllArticlesSlugs,
@@ -41,8 +40,8 @@ import {
useArticle,
useBreadcrumb,
useComments,
+ useHeadingsTree,
usePrism,
- useReadingTime,
} from '../../utils/hooks';
type ArticlePageProps = {
@@ -84,7 +83,6 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
title: article?.title ?? '',
url: `${ROUTES.ARTICLE}/${slug}`,
});
- const readingTime = useReadingTime(article?.meta.wordsCount ?? 0, true);
const { attributes, className } = usePrism({
attributes: {
'data-toolbar-order': 'show-language,copy-to-clipboard,color-scheme',
@@ -107,11 +105,21 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
description: 'ArticlePage: loading article message',
id: '4iYISO',
});
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
if (isFallback || !article) return <Spinner>{loadingArticle}</Spinner>;
const { content, id, intro, meta, title } = article;
- const { author, commentsCount, cover, dates, seo, thematics, topics } = meta;
+ const {
+ author,
+ commentsCount,
+ cover,
+ dates,
+ seo,
+ thematics,
+ topics,
+ wordsCount,
+ } = meta;
const webpageSchema = getWebPageSchema({
description: intro,
@@ -211,9 +219,15 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
id: 'HKKkQk',
description: 'SharingWidget: widget title',
});
+ const tocTitle = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ });
+ const articleComments = getComments(commentsData);
return (
- <>
+ <Page breadcrumbs={breadcrumbItems}>
<Head>
<title>{seo.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -231,135 +245,63 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- allowComments={true}
- bodyAttributes={attributes as HTMLAttributes<HTMLDivElement>}
- bodyClassName={styles.body}
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- comments={getComments(commentsData)}
- footerMeta={
- topics ? (
- <MetaList>
- <MetaItem
- hasInlinedValues
- label={intl.formatMessage({
- defaultMessage: 'Read more articles about:',
- description: 'ArticlePage: footer topics list label',
- id: '50xc4o',
- })}
- value={topics.map((topic) => {
- return {
- id: `topic--${topic.id}`,
- value: (
- <ButtonLink
- className={styles.btn}
- key={topic.id}
- to={topic.url}
- >
- {topic.logo ? <NextImage {...topic.logo} /> : null}{' '}
- {topic.name}
- </ButtonLink>
- ),
- };
- })}
- />
- </MetaList>
- ) : undefined
- }
- headerMeta={
- <MetaList>
- {author ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Written by:',
- description: 'ArticlePage: author label',
- id: 'MJbZfX',
- })}
- value={author.name}
- />
- ) : null}
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Published on:',
- description: 'Page: publication date label',
- id: '4QbTDq',
- })}
- value={<Time date={dates.publication} />}
- />
- {dates.update ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Updated on:',
- description: 'Page: update date label',
- id: 'Ez8Qim',
- })}
- value={<Time date={dates.update} />}
- />
- ) : null}
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Reading time:',
- description: 'ArticlePage: reading time label',
- id: 'Gw7X3x',
- })}
- value={readingTime}
- />
- {thematics ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Thematics:',
- description: 'ArticlePage: thematics meta label',
- id: 'CvOqoh',
- })}
- value={thematics.map((thematic) => {
- return {
- id: `thematic-${thematic.id}`,
- value: (
- <Link key={thematic.id} href={thematic.url}>
- {thematic.name}
- </Link>
- ),
- };
- })}
- />
- ) : null}
- </MetaList>
- }
- id={id as number}
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={title}
intro={intro}
- title={title}
- withToC={true}
- widgets={[
- <SharingWidget
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="sharing-widget"
- className={styles.widget}
- data={{ excerpt: intro, title, url: pageUrl }}
- heading={<Heading level={3}>{sharingWidgetTitle}</Heading>}
- media={[
- 'diaspora',
- 'email',
- 'facebook',
- 'journal-du-hacker',
- 'linkedin',
- 'twitter',
- ]}
- />,
- ]}
- >
- {contentWithPrismClasses}
- </PageLayout>
- </>
+ meta={{
+ author: author?.name,
+ publicationDate: dates.publication,
+ thematics,
+ updateDate: dates.update,
+ wordsCount,
+ }}
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={3}>{tocTitle}</Heading>}
+ tree={tree}
+ />
+ </PageSidebar>
+ <PageBody
+ {...attributes}
+ className={styles.body}
+ dangerouslySetInnerHTML={{ __html: contentWithPrismClasses }}
+ ref={ref}
+ />
+ {topics ? <PageFooter readMoreAbout={topics} /> : null}
+ <PageSidebar>
+ <SharingWidget
+ // eslint-disable-next-line react/jsx-no-literals -- Key allowed
+ key="sharing-widget"
+ className={styles.widget}
+ data={{ excerpt: intro, title, url: pageUrl }}
+ heading={<Heading level={3}>{sharingWidgetTitle}</Heading>}
+ media={[
+ 'diaspora',
+ 'email',
+ 'facebook',
+ 'journal-du-hacker',
+ 'linkedin',
+ 'twitter',
+ ]}
+ />
+ </PageSidebar>
+ <PageComments
+ comments={articleComments ?? []}
+ depth={2}
+ pageId={id as number}
+ />
+ </Page>
);
};
-ArticlePage.getLayout = (page) => getLayout(page, { useGrid: true });
+ArticlePage.getLayout = (page) => getLayout(page);
type PostParams = {
slug: string;
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 6ed6eda..0de5523 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -10,13 +10,14 @@ import {
Heading,
LinksWidget,
Notice,
- PageLayout,
PostsList,
Pagination,
type RenderPaginationLink,
type RenderPaginationItemAriaLabel,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageBody,
+ PageSidebar,
} from '../../components';
import {
getArticles,
@@ -191,7 +192,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
});
return (
- <>
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -209,60 +210,14 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- title={title}
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Total:',
- description: 'Page: total label',
- id: 'kNBXyK',
- })}
- value={intl.formatMessage(
- {
- defaultMessage:
- '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
- description: 'Page: posts count meta',
- id: 'RvGb2c',
- },
- { postsCount: totalArticles }
- )}
- />
- </MetaList>
- }
- widgets={[
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="thematics-list"
- />,
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="topics-list"
- />,
- ]}
- >
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader heading={title} meta={{ total: totalArticles }} />
+ <PageBody className={styles.body}>
{posts ? (
<PostsList
className={styles.list}
@@ -297,13 +252,36 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
})}
</Notice>
) : null}
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {thematicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ thematicsList.map((thematic) =>
+ getPageLinkFromRawData(thematic, 'thematic')
+ )
+ )}
+ />
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {topicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
+ )}
+ />
+ </PageSidebar>
+ </Page>
);
};
-BlogPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+BlogPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
locale,
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index 27d1816..b254603 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -10,13 +10,14 @@ import {
getLayout,
Heading,
LinksWidget,
- PageLayout,
PostsList,
Pagination,
type RenderPaginationLink,
type RenderPaginationItemAriaLabel,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageBody,
+ PageSidebar,
} from '../../../components';
import {
getArticles,
@@ -195,7 +196,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
});
return (
- <>
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -213,60 +214,17 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- title={pageTitleWithPageNumber}
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Total:',
- description: 'Page: total label',
- id: 'kNBXyK',
- })}
- value={intl.formatMessage(
- {
- defaultMessage:
- '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
- description: 'Page: posts count meta',
- id: 'RvGb2c',
- },
- { postsCount: totalArticles }
- )}
- />
- </MetaList>
- }
- widgets={[
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="thematics-list"
- />,
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="topics-list"
- />,
- ]}
- >
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={pageTitleWithPageNumber}
+ meta={{ total: totalArticles }}
+ />
+ <PageBody>
<PostsList posts={posts ?? []} sortByYear />
<Pagination
aria-label={paginationAriaLabel}
@@ -276,13 +234,36 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
renderLink={renderPaginationLink}
total={totalArticles}
/>
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {thematicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ thematicsList.map((thematic) =>
+ getPageLinkFromRawData(thematic, 'thematic')
+ )
+ )}
+ />
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {topicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
+ )}
+ />
+ </PageSidebar>
+ </Page>
);
};
-BlogPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+BlogPage.getLayout = (page) => getLayout(page);
type BlogPageParams = {
number: string;
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index f316143..b10d161 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -8,10 +8,13 @@ import { useIntl } from 'react-intl';
import {
ContactForm,
getLayout,
- PageLayout,
SocialMediaWidget,
Heading,
type ContactFormSubmit,
+ Page,
+ PageHeader,
+ PageBody,
+ PageSidebar,
} from '../components';
import { meta } from '../content/pages/contact.mdx';
import { sendMail } from '../services/graphql';
@@ -78,39 +81,6 @@ const ContactPage: NextPageWithLayout = () => {
description: 'ContactPage: LinkedIn profile link',
id: 'Q3oEQn',
});
-
- const widgets = [
- <SocialMediaWidget
- heading={
- <Heading isFake level={3}>
- {socialMediaTitle}
- </Heading>
- }
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="social-media"
- media={[
- {
- icon: 'Github',
- id: 'github',
- label: githubLabel,
- url: 'https://github.com/ArmandPhilippot',
- },
- {
- icon: 'Gitlab',
- id: 'gitlab',
- label: gitlabLabel,
- url: 'https://gitlab.com/ArmandPhilippot',
- },
- {
- icon: 'LinkedIn',
- id: 'linkedin',
- label: linkedinLabel,
- url: 'https://www.linkedin.com/in/armandphilippot',
- },
- ]}
- />,
- ];
-
const formName = intl.formatMessage({
defaultMessage: 'Contact form',
description: 'Contact: form accessible name',
@@ -163,7 +133,7 @@ const ContactPage: NextPageWithLayout = () => {
};
return (
- <>
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -180,21 +150,50 @@ const ContactPage: NextPageWithLayout = () => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- intro={intro}
- title={pageTitle}
- widgets={widgets}
- >
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader heading={pageTitle} intro={intro} />
+ <PageBody>
<ContactForm aria-label={formName} onSubmit={submitMail} />
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ <SocialMediaWidget
+ heading={
+ <Heading isFake level={3}>
+ {socialMediaTitle}
+ </Heading>
+ }
+ media={[
+ {
+ icon: 'Github',
+ id: 'github',
+ label: githubLabel,
+ url: 'https://github.com/ArmandPhilippot',
+ },
+ {
+ icon: 'Gitlab',
+ id: 'gitlab',
+ label: gitlabLabel,
+ url: 'https://gitlab.com/ArmandPhilippot',
+ },
+ {
+ icon: 'LinkedIn',
+ id: 'linkedin',
+ label: linkedinLabel,
+ url: 'https://www.linkedin.com/in/armandphilippot',
+ },
+ ]}
+ />
+ </PageSidebar>
+ </Page>
);
};
-ContactPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+ContactPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps = async ({ locale }) => {
const translation = await loadTranslation(locale);
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 0cda194..fd19a83 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -17,12 +17,13 @@ import {
ImageWidget,
Link,
List,
- PageLayout,
SocialMediaWidget,
ListItem,
- Time,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageSidebar,
+ TocWidget,
+ PageBody,
} from '../components';
import CVContent, { data, meta } from '../content/pages/cv.mdx';
import type { NextPageWithLayout } from '../types';
@@ -34,7 +35,7 @@ import {
getWebPageSchema,
} from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
-import { useBreadcrumb } from '../utils/hooks';
+import { useBreadcrumb, useHeadingsTree } from '../utils/hooks';
const ExternalLink = ({
children = '',
@@ -137,6 +138,7 @@ const components: MDXComponents = {
*/
const CVPage: NextPageWithLayout = () => {
const intl = useIntl();
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
const { file, image } = data;
const { dates, intro, seo, title } = meta;
const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
@@ -154,6 +156,11 @@ const CVPage: NextPageWithLayout = () => {
description: 'CVPage: social media widget title',
id: '+Dre5J',
});
+ const tocTitle = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ });
const cvCaption = intl.formatMessage(
{
@@ -186,49 +193,6 @@ const CVPage: NextPageWithLayout = () => {
id: 'Sm2wCk',
});
- const widgets = [
- <ImageWidget
- description={cvCaption}
- heading={
- <Heading isFake level={3}>
- {imageWidgetTitle}
- </Heading>
- }
- img={<NextImage {...image} />}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="image-widget"
- />,
- <SocialMediaWidget
- heading={
- <Heading isFake level={3}>
- {socialMediaTitle}
- </Heading>
- }
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="social-media"
- media={[
- {
- icon: 'Github',
- id: 'github',
- label: githubLabel,
- url: PERSONAL_LINKS.GITHUB,
- },
- {
- icon: 'Gitlab',
- id: 'gitlab',
- label: gitlabLabel,
- url: PERSONAL_LINKS.GITLAB,
- },
- {
- icon: 'LinkedIn',
- id: 'linkedin',
- label: linkedinLabel,
- url: PERSONAL_LINKS.LINKEDIN,
- },
- ]}
- />,
- ];
-
const { asPath } = useRouter();
const webpageSchema = getWebPageSchema({
description: seo.description,
@@ -254,38 +218,7 @@ const CVPage: NextPageWithLayout = () => {
};
return (
- <PageLayout
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Published on:',
- description: 'Page: publication date label',
- id: '4QbTDq',
- })}
- value={<Time date={dates.publication} />}
- />
- {dates.update ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Updated on:',
- description: 'Page: update date label',
- id: 'Ez8Qim',
- })}
- value={<Time date={dates.update} />}
- />
- ) : null}
- </MetaList>
- }
- intro={intro}
- title={title}
- widgets={widgets}
- withToC={true}
- >
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -304,13 +237,72 @@ const CVPage: NextPageWithLayout = () => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <CVContent components={components} />
- </PageLayout>
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={title}
+ intro={intro}
+ meta={{
+ publicationDate: dates.publication,
+ updateDate: dates.update,
+ }}
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={3}>{tocTitle}</Heading>}
+ tree={tree}
+ />
+ </PageSidebar>
+ <PageBody ref={ref}>
+ <CVContent components={components} />
+ </PageBody>
+ <PageSidebar>
+ <ImageWidget
+ description={cvCaption}
+ heading={
+ <Heading isFake level={3}>
+ {imageWidgetTitle}
+ </Heading>
+ }
+ img={<NextImage {...image} />}
+ />
+ <SocialMediaWidget
+ heading={
+ <Heading isFake level={3}>
+ {socialMediaTitle}
+ </Heading>
+ }
+ media={[
+ {
+ icon: 'Github',
+ id: 'github',
+ label: githubLabel,
+ url: PERSONAL_LINKS.GITHUB,
+ },
+ {
+ icon: 'Gitlab',
+ id: 'gitlab',
+ label: gitlabLabel,
+ url: PERSONAL_LINKS.GITLAB,
+ },
+ {
+ icon: 'LinkedIn',
+ id: 'linkedin',
+ label: linkedinLabel,
+ url: PERSONAL_LINKS.LINKEDIN,
+ },
+ ]}
+ />
+ </PageSidebar>
+ </Page>
);
};
-CVPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+CVPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps = async ({ locale }) => {
const translation = await loadTranslation(locale);
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index d708ac5..81883fc 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -438,8 +438,7 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
);
};
-HomePage.getLayout = (page) =>
- getLayout(page, { isHome: true, withExtraPadding: false });
+HomePage.getLayout = (page) => getLayout(page, { isHome: true });
export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
const translation = await loadTranslation(locale);
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index e07263f..e3aabc5 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -9,11 +9,13 @@ import { useIntl } from 'react-intl';
import {
getLayout,
Link,
- PageLayout,
Figure,
- Time,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageSidebar,
+ TocWidget,
+ Heading,
+ PageBody,
} from '../components';
import LegalNoticeContent, { meta } from '../content/pages/legal-notice.mdx';
import type { NextPageWithLayout } from '../types';
@@ -25,7 +27,7 @@ import {
getWebPageSchema,
} from '../utils/helpers';
import { loadTranslation } from '../utils/helpers/server';
-import { useBreadcrumb } from '../utils/hooks';
+import { useBreadcrumb, useHeadingsTree } from '../utils/hooks';
const ResponsiveImage = (props: NextImageProps) => (
<Figure>
@@ -49,6 +51,7 @@ const LegalNoticePage: NextPageWithLayout = () => {
url: ROUTES.LEGAL_NOTICE,
});
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
const { asPath } = useRouter();
const webpageSchema = getWebPageSchema({
description: seo.description,
@@ -71,39 +74,14 @@ const LegalNoticePage: NextPageWithLayout = () => {
title: `${seo.title} - ${CONFIG.name}`,
url: `${CONFIG.url}${asPath}`,
};
+ const tocTitle = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ });
return (
- <PageLayout
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Published on:',
- description: 'Page: publication date label',
- id: '4QbTDq',
- })}
- value={<Time date={dates.publication} />}
- />
- {dates.update ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Updated on:',
- description: 'Page: update date label',
- id: 'Ez8Qim',
- })}
- value={<Time date={dates.update} />}
- />
- ) : null}
- </MetaList>
- }
- intro={intro}
- title={title}
- withToC={true}
- >
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -120,13 +98,34 @@ const LegalNoticePage: NextPageWithLayout = () => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <LegalNoticeContent components={components} />
- </PageLayout>
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={title}
+ intro={intro}
+ meta={{
+ publicationDate: dates.publication,
+ updateDate: dates.update,
+ }}
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={3}>{tocTitle}</Heading>}
+ tree={tree}
+ />
+ </PageSidebar>
+ <PageBody ref={ref}>
+ <LegalNoticeContent components={components} />
+ </PageBody>
+ </Page>
);
};
-LegalNoticePage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+LegalNoticePage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps = async ({ locale }) => {
const translation = await loadTranslation(locale);
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index a8a4fea..82d9149 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -12,20 +12,21 @@ import {
Code,
getLayout,
Link,
- PageLayout,
SharingWidget,
Spinner,
Heading,
List,
ListItem,
Figure,
- Time,
Grid,
ProjectOverview,
type ProjectMeta,
type Repository,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageSidebar,
+ TocWidget,
+ PageBody,
} from '../../components';
import styles from '../../styles/pages/project.module.scss';
import type { NextPageWithLayout, ProjectPreview, Repos } from '../../types';
@@ -42,7 +43,11 @@ import {
loadTranslation,
type Messages,
} from '../../utils/helpers/server';
-import { useBreadcrumb, useGithubApi } from '../../utils/hooks';
+import {
+ useBreadcrumb,
+ useGithubApi,
+ useHeadingsTree,
+} from '../../utils/hooks';
const BorderedImage = (props: NextImageProps) => (
<Figure hasBorders>
@@ -164,6 +169,7 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
title,
url: `${ROUTES.PROJECTS}/${id}`,
});
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
const ProjectContent: ComponentType<MDXComponents> = dynamic(
async () => import(`../../content/projects/${id}.mdx`),
@@ -269,9 +275,14 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
id: 'HKKkQk',
description: 'SharingWidget: widget title',
});
+ const tocTitle = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ });
return (
- <>
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -289,67 +300,54 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- title={title}
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={title}
intro={intro}
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Published on:',
- description: 'Page: publication date label',
- id: '4QbTDq',
- })}
- value={<Time date={dates.publication} />}
- />
- {dates.update ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Updated on:',
- description: 'Page: update date label',
- id: 'Ez8Qim',
- })}
- value={<Time date={dates.update} />}
- />
- ) : null}
- </MetaList>
- }
- withToC={true}
- widgets={[
- <SharingWidget
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="sharing-widget"
- data={{ excerpt: intro, title, url: page.url }}
- heading={<Heading level={3}>{sharingWidgetTitle}</Heading>}
- media={[
- 'diaspora',
- 'email',
- 'facebook',
- 'journal-du-hacker',
- 'linkedin',
- 'twitter',
- ]}
- className={styles.widget}
- />,
- ]}
- >
+ meta={{
+ publicationDate: dates.publication,
+ updateDate: dates.update,
+ }}
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={3}>{tocTitle}</Heading>}
+ tree={tree}
+ />
+ </PageSidebar>
+ <PageBody ref={ref}>
<ProjectOverview
cover={cover ? <NextImage {...cover} /> : undefined}
meta={overviewMeta}
name={project.title}
/>
<ProjectContent components={components} />
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ <SharingWidget
+ data={{ excerpt: intro, title, url: page.url }}
+ heading={<Heading level={3}>{sharingWidgetTitle}</Heading>}
+ media={[
+ 'diaspora',
+ 'email',
+ 'facebook',
+ 'journal-du-hacker',
+ 'linkedin',
+ 'twitter',
+ ]}
+ className={styles.widget}
+ />
+ </PageSidebar>
+ </Page>
);
};
-ProjectPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+ProjectPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
locale,
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 8feb701..0b9a91c 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -18,8 +18,10 @@ import {
type GridItem,
Link,
MetaList,
- PageLayout,
MetaItem,
+ Page,
+ PageHeader,
+ PageBody,
} from '../../components';
import PageContent, { meta } from '../../content/pages/projects.mdx';
import styles from '../../styles/pages/projects.module.scss';
@@ -139,7 +141,7 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
};
return (
- <>
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -157,12 +159,17 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- title={title}
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={title}
intro={<PageContent components={components} />}
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- >
+ />
+ <PageBody className={styles.body}>
<Grid
className={styles.list}
gap="sm"
@@ -170,13 +177,12 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
items={items}
sizeMax="30ch"
/>
- </PageLayout>
- </>
+ </PageBody>
+ </Page>
);
};
-ProjectsPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+ProjectsPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({
locale,
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 0fb279b..2a18aa3 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -10,13 +10,14 @@ import {
Heading,
LinksWidget,
Notice,
- PageLayout,
PostsList,
Spinner,
SearchForm,
type SearchFormSubmit,
- MetaList,
- MetaItem,
+ PageHeader,
+ Page,
+ PageSidebar,
+ PageBody,
} from '../../components';
import {
getArticles,
@@ -172,7 +173,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
);
return (
- <>
+ <Page breadcrumbs={breadcrumbItems} isBodyLastChild>
<Head>
<title>{page.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -190,60 +191,14 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- title={title}
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Total:',
- description: 'Page: total label',
- id: 'kNBXyK',
- })}
- value={intl.formatMessage(
- {
- defaultMessage:
- '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
- description: 'Page: posts count meta',
- id: 'RvGb2c',
- },
- { postsCount: totalArticles }
- )}
- />
- </MetaList>
- }
- widgets={[
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- thematicsList.map((thematic) =>
- getPageLinkFromRawData(thematic, 'thematic')
- )
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="thematics-list"
- />,
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(
- topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
- )}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="topics-list"
- />,
- ]}
- >
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader heading={title} meta={{ total: totalArticles }} />
+ <PageBody className={styles.body}>
{posts ? null : <Spinner>{loadingResults}</Spinner>}
{posts?.length ? (
<PostsList
@@ -285,13 +240,36 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
})}
</Notice>
) : null}
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {thematicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ thematicsList.map((thematic) =>
+ getPageLinkFromRawData(thematic, 'thematic')
+ )
+ )}
+ />
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {topicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(
+ topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
+ )}
+ />
+ </PageSidebar>
+ </Page>
);
};
-SearchPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+SearchPage.getLayout = (page) => getLayout(page);
export const getStaticProps: GetStaticProps<SearchPageProps> = async ({
locale,
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index d9734a3..30adec3 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -10,11 +10,12 @@ import {
getLayout,
Heading,
LinksWidget,
- PageLayout,
PostsList,
- Time,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageSidebar,
+ TocWidget,
+ PageBody,
} from '../../components';
import {
getAllTopicsSlugs,
@@ -35,7 +36,7 @@ import {
getWebPageSchema,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
-import { useBreadcrumb } from '../../utils/hooks';
+import { useBreadcrumb, useHeadingsTree } from '../../utils/hooks';
export type TopicPageProps = {
currentTopic: Topic;
@@ -61,6 +62,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
title,
url: `${ROUTES.TOPICS}/${slug}`,
});
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
const { asPath } = useRouter();
const webpageSchema = getWebPageSchema({
@@ -101,9 +103,14 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
</>
);
const pageUrl = `${CONFIG.url}${asPath}`;
+ const tocTitle = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ });
return (
- <>
+ <Page breadcrumbs={breadcrumbItems}>
<Head>
<title>{seo.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -121,92 +128,29 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- title={getPageHeading()}
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={getPageHeading()}
intro={intro}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Published on:',
- description: 'Page: publication date label',
- id: '4QbTDq',
- })}
- value={<Time date={dates.publication} />}
- />
- {dates.update ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Updated on:',
- description: 'Page: update date label',
- id: 'Ez8Qim',
- })}
- value={<Time date={dates.update} />}
- />
- ) : null}
- {officialWebsite ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Official website:',
- description: 'TopicPage: official website label',
- id: 'zoifQd',
- })}
- value={officialWebsite}
- />
- ) : null}
- {articles ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Total:',
- description: 'ThematicPage: total label',
- id: 'lHkta9',
- })}
- value={intl.formatMessage(
- {
- defaultMessage:
- '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
- description: 'ThematicPage: posts count meta',
- id: 'iv3Ex1',
- },
- { postsCount: articles.length }
- )}
- />
- ) : null}
- </MetaList>
- }
- widgets={
- thematics
- ? [
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(thematics)}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="related-thematics"
- />,
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(topics)}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="topics"
- />,
- ]
- : []
- }
- >
+ meta={{
+ publicationDate: dates.publication,
+ total: articles?.length,
+ updateDate: dates.update,
+ website: officialWebsite,
+ }}
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={3}>{tocTitle}</Heading>}
+ tree={tree}
+ />
+ </PageSidebar>
+ <PageBody className={styles.body} ref={ref}>
{/*eslint-disable-next-line react/no-danger -- Necessary for content*/}
{content ? <div dangerouslySetInnerHTML={{ __html: content }} /> : null}
{articles ? (
@@ -229,13 +173,32 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
/>
</>
) : null}
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ {thematics ? (
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {thematicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(thematics)}
+ />
+ ) : null}
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {topicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(topics)}
+ />
+ </PageSidebar>
+ </Page>
);
};
-TopicPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+TopicPage.getLayout = (page) => getLayout(page);
type TopicParams = {
slug: string;
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 9220ccd..b8518c5 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -9,11 +9,12 @@ import {
getLayout,
Heading,
LinksWidget,
- PageLayout,
PostsList,
- Time,
- MetaList,
- MetaItem,
+ Page,
+ PageHeader,
+ PageSidebar,
+ TocWidget,
+ PageBody,
} from '../../components';
import {
getAllThematicsSlugs,
@@ -34,7 +35,7 @@ import {
getWebPageSchema,
} from '../../utils/helpers';
import { loadTranslation, type Messages } from '../../utils/helpers/server';
-import { useBreadcrumb } from '../../utils/hooks';
+import { useBreadcrumb, useHeadingsTree } from '../../utils/hooks';
export type ThematicPageProps = {
currentThematic: Thematic;
@@ -53,6 +54,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
title,
url: `${ROUTES.THEMATICS.INDEX}/${slug}`,
});
+ const { ref, tree } = useHeadingsTree({ fromLevel: 2 });
const { asPath } = useRouter();
const webpageSchema = getWebPageSchema({
@@ -85,9 +87,14 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
id: '/42Z0z',
});
const pageUrl = `${CONFIG.url}${asPath}`;
+ const tocTitle = intl.formatMessage({
+ defaultMessage: 'Table of Contents',
+ description: 'PageLayout: table of contents title',
+ id: 'eys2uX',
+ });
return (
- <>
+ <Page breadcrumbs={breadcrumbItems}>
<Head>
<title>{seo.title}</title>
{/*eslint-disable-next-line react/jsx-no-literals -- Name allowed */}
@@ -105,81 +112,28 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
// eslint-disable-next-line react/no-danger -- Necessary for schema
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
/>
- <PageLayout
- breadcrumb={breadcrumbItems}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
+ <Script
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ // eslint-disable-next-line react/jsx-no-literals -- Id allowed
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ />
+ <PageHeader
+ heading={title}
intro={intro}
- headerMeta={
- <MetaList>
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Published on:',
- description: 'Page: publication date label',
- id: '4QbTDq',
- })}
- value={<Time date={dates.publication} />}
- />
- {dates.update ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Updated on:',
- description: 'Page: update date label',
- id: 'Ez8Qim',
- })}
- value={<Time date={dates.update} />}
- />
- ) : null}
- {articles ? (
- <MetaItem
- isInline
- label={intl.formatMessage({
- defaultMessage: 'Total:',
- description: 'ThematicPage: total label',
- id: 'lHkta9',
- })}
- value={intl.formatMessage(
- {
- defaultMessage:
- '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
- description: 'ThematicPage: posts count meta',
- id: 'iv3Ex1',
- },
- { postsCount: articles.length }
- )}
- />
- ) : null}
- </MetaList>
- }
- widgets={
- topics
- ? [
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {thematicsListTitle}
- </Heading>
- }
- items={getLinksItemData(thematics)}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="thematics"
- />,
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- {topicsListTitle}
- </Heading>
- }
- items={getLinksItemData(topics)}
- // eslint-disable-next-line react/jsx-no-literals -- Key allowed
- key="related-topics"
- />,
- ]
- : []
- }
- >
+ meta={{
+ publicationDate: dates.publication,
+ total: articles?.length,
+ updateDate: dates.update,
+ }}
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={3}>{tocTitle}</Heading>}
+ tree={tree}
+ />
+ </PageSidebar>
+ <PageBody className={styles.body} ref={ref}>
{/*eslint-disable-next-line react/no-danger -- Necessary for content*/}
<div dangerouslySetInnerHTML={{ __html: content }} />
{articles ? (
@@ -202,13 +156,32 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
/>
</>
) : null}
- </PageLayout>
- </>
+ </PageBody>
+ <PageSidebar>
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {thematicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(thematics)}
+ />
+ {topics ? (
+ <LinksWidget
+ heading={
+ <Heading isFake level={3}>
+ {topicsListTitle}
+ </Heading>
+ }
+ items={getLinksItemData(topics)}
+ />
+ ) : null}
+ </PageSidebar>
+ </Page>
);
};
-ThematicPage.getLayout = (page) =>
- getLayout(page, { useGrid: true, withExtraPadding: true });
+ThematicPage.getLayout = (page) => getLayout(page);
type ThematicParams = {
slug: string;
diff --git a/src/styles/layout/_grid.scss b/src/styles/layout/_grid.scss
deleted file mode 100644
index 3e6e7de..0000000
--- a/src/styles/layout/_grid.scss
+++ /dev/null
@@ -1,27 +0,0 @@
-@use "../abstracts/mixins" as mix;
-
-.grid {
- --grid-gap: var(--spacing-md);
-
- display: grid;
- align-items: center;
- grid-template-columns:
- minmax(0, 1fr) min(calc(100vw - calc(var(--spacing-md) * 2)), 80ch)
- var(--column-3, minmax(0, 1fr));
- column-gap: var(--grid-gap);
-
- @include mix.media("screen") {
- @include mix.dimensions("md") {
- --grid-gap: var(--spacing-xl);
- grid-template-columns:
- minmax(0, 1fr) clamp(60ch, 60vw, 80ch)
- var(--column-3, minmax(0, 3fr));
- }
-
- @include mix.dimensions("lg") {
- grid-template-columns:
- minmax(0, 1fr) clamp(47ch, 47vw, 80ch)
- var(--column-3, minmax(0, 1fr));
- }
- }
-}
diff --git a/src/styles/pages/project.module.scss b/src/styles/pages/project.module.scss
index 0bbd77d..69c0f8d 100644
--- a/src/styles/pages/project.module.scss
+++ b/src/styles/pages/project.module.scss
@@ -3,8 +3,9 @@
.widget {
@include mix.media("screen") {
@include mix.dimensions("md") {
- width: min-content;
- gap: var(--spacing-2xs);
+ > ul {
+ width: min-content;
+ }
}
}
}
diff --git a/src/types/app.ts b/src/types/app.ts
index 2588a7b..b613e6e 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -5,9 +5,7 @@ import type { MessageFormatElement } from 'react-intl';
import type { VALID_THEMES } from '../utils/constants';
export type NextPageWithLayoutOptions = {
- withExtraPadding?: boolean;
isHome?: boolean;
- useGrid?: boolean;
};
export type NextPageWithLayout<T = object> = NextPage<T> & {
diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts
index b3fa627..240a092 100644
--- a/src/utils/hooks/index.ts
+++ b/src/utils/hooks/index.ts
@@ -17,7 +17,6 @@ export * from './use-pagination';
export * from './use-posts-list';
export * from './use-prism';
export * from './use-prism-theme';
-export * from './use-reading-time';
export * from './use-redirection';
export * from './use-reduced-motion';
export * from './use-scroll-lock';
diff --git a/src/utils/hooks/use-reading-time.tsx b/src/utils/hooks/use-reading-time.tsx
deleted file mode 100644
index 3a812f6..0000000
--- a/src/utils/hooks/use-reading-time.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useIntl } from 'react-intl';
-
-/**
- * Retrieve the estimated reading time by words count.
- *
- * @param {number} wordsCount - The number of words.
- * @returns {string} The estimated reading time.
- */
-export const useReadingTime = (
- wordsCount: number,
- onlyMinutes: boolean = false
-): string => {
- const intl = useIntl();
- const wordsPerMinute = 245;
- const wordsPerSecond = wordsPerMinute / 60;
- const estimatedTimeInSeconds = wordsCount / wordsPerSecond;
-
- if (onlyMinutes) {
- const estimatedTimeInMinutes = Math.round(estimatedTimeInSeconds / 60);
-
- return intl.formatMessage(
- {
- defaultMessage: '{minutesCount} minutes',
- description: 'useReadingTime: rounded minutes count',
- id: 's1i43J',
- },
- { minutesCount: estimatedTimeInMinutes }
- );
- } else {
- const estimatedTimeInMinutes = Math.floor(estimatedTimeInSeconds / 60);
-
- if (estimatedTimeInMinutes <= 0) {
- return intl.formatMessage(
- {
- defaultMessage: '{count} seconds',
- description: 'useReadingTime: seconds count',
- id: 'i7Wq3G',
- },
- { count: estimatedTimeInSeconds.toFixed(0) }
- );
- }
-
- const remainingSeconds = Math.round(
- estimatedTimeInSeconds - estimatedTimeInMinutes * 60
- ).toFixed(0);
-
- return intl.formatMessage(
- {
- defaultMessage: '{minutesCount} minutes {secondsCount} seconds',
- description: 'useReadingTime: minutes + seconds count',
- id: 'OevMeU',
- },
- { minutesCount: estimatedTimeInMinutes, secondsCount: remainingSeconds }
- );
- }
-};