summaryrefslogtreecommitdiffstats
path: root/src/components/templates
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/templates')
-rw-r--r--src/components/templates/layout/layout.module.scss53
-rw-r--r--src/components/templates/layout/layout.stories.tsx117
-rw-r--r--src/components/templates/layout/layout.test.tsx35
-rw-r--r--src/components/templates/layout/layout.tsx242
-rw-r--r--src/components/templates/page/page-layout.module.scss92
-rw-r--r--src/components/templates/page/page-layout.stories.tsx387
-rw-r--r--src/components/templates/page/page-layout.test.tsx107
-rw-r--r--src/components/templates/page/page-layout.tsx297
-rw-r--r--src/components/templates/sectioned/sectioned-layout.stories.tsx80
-rw-r--r--src/components/templates/sectioned/sectioned-layout.test.tsx41
-rw-r--r--src/components/templates/sectioned/sectioned-layout.tsx60
11 files changed, 1511 insertions, 0 deletions
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
new file mode 100644
index 0000000..1080732
--- /dev/null
+++ b/src/components/templates/layout/layout.module.scss
@@ -0,0 +1,53 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.header {
+ border-bottom: fun.convert-px(3) solid var(--color-border-light);
+}
+
+.main {
+ flex: 1;
+}
+
+.article {
+ &--grid {
+ @extend %grid;
+
+ grid-auto-flow: column dense;
+ align-items: baseline;
+ }
+
+ &--padding {
+ padding-bottom: var(--spacing-lg);
+ }
+}
+
+.footer {
+ border-top: fun.convert-px(3) solid var(--color-border-light);
+}
+
+.back-to-top {
+ &--hidden {
+ opacity: 0;
+ transform: translateY(calc(var(--button-size) + var(--spacing-md)));
+ visibility: hidden;
+ }
+
+ &--visible {
+ opacity: 1;
+ transform: translateY(0);
+ visibility: visible;
+ }
+}
+
+.noscript-spacing {
+ width: 100%;
+ height: fun.convert-px(75);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ height: fun.convert-px(50);
+ }
+ }
+}
diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
new file mode 100644
index 0000000..4666b07
--- /dev/null
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -0,0 +1,117 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LayoutComponent from './layout';
+
+/**
+ * Layout - Storybook Meta
+ */
+export default {
+ title: 'Templates/LayoutBase',
+ component: LayoutComponent,
+ args: {
+ breadcrumbSchema: [],
+ isHome: false,
+ },
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The article content.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ breadcrumbSchema: {
+ control: {
+ type: 'null',
+ },
+ description: 'The JSON schema for breadcrumb items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ isHome: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if it is the homepage.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the article element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ useGrid: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Use the grid layout.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ withExtraPadding: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Set additional padding at the bottom of the page.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+ <div
+ id="__next"
+ style={{
+ flex: 1,
+ display: 'flex',
+ flexFlow: 'column nowrap',
+ minHeight: '100vh',
+ }}
+ >
+ <Story />
+ </div>
+ ),
+ ],
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof LayoutComponent>;
+
+const Template: ComponentStory<typeof LayoutComponent> = (args) => (
+ <LayoutComponent {...args} />
+);
+
+/**
+ * Layout Stories - Default
+ */
+export const LayoutBase = Template.bind({});
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
new file mode 100644
index 0000000..78547d4
--- /dev/null
+++ b/src/components/templates/layout/layout.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
+import Layout from './layout';
+
+const body =
+ 'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
+
+describe('Layout', () => {
+ it('renders the website header', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('banner')).toBeInTheDocument();
+ });
+
+ it('renders the website main content', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('main')).toBeInTheDocument();
+ });
+
+ it('renders the website footer', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument();
+ });
+
+ it('renders a skip to content link', () => {
+ render(<Layout>{body}</Layout>);
+ expect(
+ screen.getByRole('link', { name: 'Skip to content' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an article', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('article')).toHaveTextContent(body);
+ });
+});
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
new file mode 100644
index 0000000..8f0d4e7
--- /dev/null
+++ b/src/components/templates/layout/layout.tsx
@@ -0,0 +1,242 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Career from '@components/atoms/icons/career';
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import ComputerScreen from '@components/atoms/icons/computer-screen';
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import PostsStack from '@components/atoms/icons/posts-stack';
+import Main from '@components/atoms/layout/main';
+import NoScript from '@components/atoms/layout/no-script';
+import Footer, { type FooterProps } from '@components/organisms/layout/footer';
+import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import { type NextPageWithLayoutOptions } from '@ts/types/app';
+import useScrollPosition from '@utils/hooks/use-scroll-position';
+import useSettings from '@utils/hooks/use-settings';
+import Script from 'next/script';
+import { FC, ReactElement, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
+import styles from './layout.module.scss';
+
+export type QueryAction = SearchAction & {
+ 'query-input': string;
+};
+
+export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
+ /**
+ * The layout main content.
+ */
+ children: ReactNode;
+ /**
+ * Determine if article has a comments section.
+ */
+ withExtraPadding?: boolean;
+ /**
+ * Determine if article should use grid. Default: false.
+ */
+ useGrid?: boolean;
+};
+
+/**
+ * Layout component
+ *
+ * Render the base layout used by all pages.
+ */
+const Layout: FC<LayoutProps> = ({
+ children,
+ withExtraPadding = false,
+ isHome,
+ useGrid = false,
+}) => {
+ const intl = useIntl();
+ const { website } = useSettings();
+ const { baseline, copyright, locales, name, picture, url } = website;
+ const articleGridClass = useGrid ? 'article--grid' : '';
+ const articleCommentsClass = withExtraPadding ? 'article--padding' : '';
+
+ const skipToContent = intl.formatMessage({
+ defaultMessage: 'Skip to content',
+ description: 'Layout: Skip to content link',
+ id: 'K4rYdT',
+ });
+ const noScript = intl.formatMessage({
+ defaultMessage:
+ 'Warning: If you want to benefit from all features (search for example), please activate Javascript.',
+ description: 'Layout: noscript message',
+ id: '7jVUT6',
+ });
+
+ const copyrightData = {
+ dates: {
+ start: copyright.start,
+ end: copyright.end,
+ },
+ owner: name,
+ icon: <CCBySA />,
+ };
+
+ const homeLabel = intl.formatMessage({
+ defaultMessage: 'Home',
+ description: 'Layout: main nav - home link',
+ id: 'bojYF5',
+ });
+ const blogLabel = intl.formatMessage({
+ defaultMessage: 'Blog',
+ description: 'Layout: main nav - blog link',
+ id: 'D8vB38',
+ });
+ const projectsLabel = intl.formatMessage({
+ defaultMessage: 'Projects',
+ description: 'Layout: main nav - projects link',
+ id: 'qnwsWV',
+ });
+ const cvLabel = intl.formatMessage({
+ defaultMessage: 'CV',
+ description: 'Layout: main nav - cv link',
+ id: 'R895yC',
+ });
+ const contactLabel = intl.formatMessage({
+ defaultMessage: 'Contact',
+ description: 'Layout: main nav - contact link',
+ id: 'AE4kCD',
+ });
+
+ const mainNav: HeaderProps['nav'] = [
+ { id: 'home', label: homeLabel, href: '/', logo: <Home /> },
+ { id: 'blog', label: blogLabel, href: '/blog', logo: <PostsStack /> },
+ {
+ id: 'projects',
+ label: projectsLabel,
+ href: '/projets',
+ logo: <ComputerScreen />,
+ },
+ { id: 'cv', label: cvLabel, href: '/cv', logo: <Career /> },
+ { id: 'contact', label: contactLabel, href: '/contact', logo: <Envelop /> },
+ ];
+
+ const legalNoticeLabel = intl.formatMessage({
+ defaultMessage: 'Legal notice',
+ description: 'Layout: Legal notice label',
+ id: 'nwbzKm',
+ });
+
+ const footerNav: FooterProps['navItems'] = [
+ { id: 'legal-notice', label: legalNoticeLabel, href: '/mentions-legales' },
+ ];
+
+ const searchActionSchema: QueryAction = {
+ '@type': 'SearchAction',
+ target: {
+ '@type': 'EntryPoint',
+ urlTemplate: `${url}/recherche?s={search_term_string}`,
+ },
+ query: 'required',
+ 'query-input': 'required name=search_term_string',
+ };
+
+ const schemaJsonLd: WithContext<WebSite> = {
+ '@context': 'https://schema.org',
+ '@id': `${url}`,
+ '@type': 'WebSite',
+ name: name,
+ description: baseline,
+ url: url,
+ author: { '@id': `${url}/#branding` },
+ copyrightYear: Number(copyright.start),
+ creator: { '@id': `${url}/#branding` },
+ editor: { '@id': `${url}/#branding` },
+ inLanguage: locales.default,
+ potentialAction: searchActionSchema,
+ };
+
+ const brandingSchema: WithContext<Person> = {
+ '@context': 'https://schema.org',
+ '@type': 'Person',
+ '@id': `${url}/#branding`,
+ name: name,
+ url: url,
+ jobTitle: baseline,
+ image: picture.src,
+ subjectOf: { '@id': `${url}` },
+ };
+
+ const [backToTopClassName, setBackToTopClassName] = useState<string>(
+ styles['back-to-top--hidden']
+ );
+ const updateBackToTopClassName = () => {
+ setBackToTopClassName(
+ window.scrollY > 300
+ ? styles['back-to-top--visible']
+ : styles['back-to-top--hidden']
+ );
+ };
+
+ useScrollPosition(updateBackToTopClassName);
+
+ return (
+ <>
+ <Script
+ id="schema-layout"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+ />
+ <Script
+ id="schema-branding"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
+ />
+ <noscript>
+ <div className={styles['noscript-spacing']}></div>
+ </noscript>
+ <span tabIndex={-1}></span>
+ <ButtonLink target="#main" className="screen-reader-text">
+ {skipToContent}
+ </ButtonLink>
+ <Header
+ ackeeStorageKey="ackee-tracking"
+ baseline={baseline}
+ className={styles.header}
+ isHome={isHome}
+ motionStorageKey="reduced-motion"
+ nav={mainNav}
+ photo={picture}
+ searchPage="/recherche"
+ title={name}
+ withLink={true}
+ />
+ <Main id="main" className={styles.main}>
+ <article
+ className={`${styles[articleGridClass]} ${styles[articleCommentsClass]}`}
+ >
+ {children}
+ </article>
+ </Main>
+ <Footer
+ copyright={copyrightData}
+ navItems={footerNav}
+ topId="top"
+ backToTopClassName={backToTopClassName}
+ className={styles.footer}
+ />
+ <noscript>
+ <NoScript message={noScript} position="top" />
+ </noscript>
+ </>
+ );
+};
+
+/**
+ * Get the global layout.
+ *
+ * @param {ReactElement} page - A page.
+ * @param {boolean} [isHome] - Determine if it is the homepage.
+ * @returns A page wrapped with the global layout.
+ */
+export const getLayout = (
+ page: ReactElement,
+ props: NextPageWithLayoutOptions
+) => {
+ return <Layout {...props}>{page}</Layout>;
+};
+
+export default Layout;
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
new file mode 100644
index 0000000..c7674ae
--- /dev/null
+++ b/src/components/templates/page/page-layout.module.scss
@@ -0,0 +1,92 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.breadcrumb {
+ @extend %grid;
+
+ grid-column: 1 / -1;
+ padding: var(--spacing-md) 0;
+
+ > * {
+ grid-column: 2;
+ }
+
+ &__items {
+ 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;
+
+ &:first-child {
+ margin: var(--spacing-md) 0 0;
+ }
+ }
+
+ &__no-comments {
+ text-align: center;
+ }
+
+ &__form {
+ max-width: 40ch;
+ margin: auto;
+ }
+}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
new file mode 100644
index 0000000..06c6c24
--- /dev/null
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -0,0 +1,387 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading from '@components/atoms/headings/heading';
+import Link from '@components/atoms/links/link';
+import { comments } from '@components/organisms/layout/comments-list.fixture';
+import PostsList from '@components/organisms/layout/posts-list';
+import { posts } from '@components/organisms/layout/posts-list.fixture';
+import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import Sharing from '@components/organisms/widgets/sharing';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
+import 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: [
+ <Sharing
+ key="sidebar2-widget1"
+ data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
+ media={[
+ 'diaspora',
+ 'email',
+ 'facebook',
+ 'journal-du-hacker',
+ 'linkedin',
+ 'twitter',
+ ]}
+ level={2}
+ expanded={true}
+ />,
+ ],
+ 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: {
+ publication: { date: '2020-03-14' },
+ thematics: [
+ <Link key="cat1" href="#">
+ Cat 1
+ </Link>,
+ <Link key="cat2" href="#">
+ Cat 2
+ </Link>,
+ ],
+ },
+ footerMeta: {
+ custom: {
+ label: 'Read more about:',
+ value: <ButtonLink target="#">Topic 1</ButtonLink>,
+ },
+ },
+ 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: [
+ <Sharing
+ key="sidebar2-widget1"
+ data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
+ media={[
+ 'diaspora',
+ 'email',
+ 'facebook',
+ 'journal-du-hacker',
+ 'linkedin',
+ 'twitter',
+ ]}
+ level={2}
+ expanded={true}
+ />,
+ ],
+ withToC: true,
+ comments: comments,
+ allowComments: true,
+};
+
+const postsListBreadcrumb = [
+ { id: 'home', url: '#', name: 'Home' },
+ { id: 'blog', url: '#', name: 'Blog' },
+];
+
+const blogCategories = [
+ { name: 'Cat 1', url: '#' },
+ {
+ name: 'Cat 2',
+ url: '#',
+ },
+ { name: 'Cat 3', url: '#' },
+ { name: 'Cat 4', url: '#' },
+];
+
+/**
+ * Page Layout Stories - Posts list
+ */
+export const Blog = Template.bind({});
+Blog.args = {
+ breadcrumb: postsListBreadcrumb,
+ title: 'Blog',
+ headerMeta: { total: posts.length },
+ children: (
+ <>
+ <PostsList
+ posts={posts}
+ byYear={true}
+ total={posts.length}
+ searchPage="#"
+ />
+ </>
+ ),
+ widgets: [
+ <LinksListWidget
+ key="sidebar-widget1"
+ items={blogCategories}
+ title="Categories"
+ level={2}
+ />,
+ ],
+};
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
new file mode 100644
index 0000000..f2d07d7
--- /dev/null
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -0,0 +1,107 @@
+import { comments } from '@components/organisms/layout/comments-list.fixture';
+import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
+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(
+ screen.getByRole('heading', { level: 1, name: title })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the page content', () => {
+ render(
+ <PageLayout
+ breadcrumb={breadcrumb}
+ breadcrumbSchema={breadcrumbSchema}
+ title={title}
+ >
+ {children}
+ </PageLayout>
+ );
+ expect(screen.getByText(children)).toBeInTheDocument();
+ });
+
+ it('renders the breadcrumb', () => {
+ render(
+ <PageLayout
+ breadcrumb={breadcrumb}
+ breadcrumbSchema={breadcrumbSchema}
+ title={title}
+ >
+ {children}
+ </PageLayout>
+ );
+ expect(
+ screen.getByRole('navigation', { name: 'Breadcrumb' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the table of contents', () => {
+ render(
+ <PageLayout
+ breadcrumb={breadcrumb}
+ breadcrumbSchema={breadcrumbSchema}
+ title={title}
+ withToC={true}
+ >
+ {children}
+ </PageLayout>
+ );
+ expect(
+ screen.getByRole('heading', { level: 2, name: /Table of Contents/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the comment form', () => {
+ render(
+ <PageLayout
+ breadcrumb={breadcrumb}
+ breadcrumbSchema={breadcrumbSchema}
+ title={title}
+ allowComments={true}
+ >
+ {children}
+ </PageLayout>
+ );
+ expect(
+ screen.getByRole('form', { name: /Leave a comment/i })
+ ).toBeInTheDocument();
+ });
+
+ it('renders the comments list', () => {
+ render(
+ <PageLayout
+ breadcrumb={breadcrumb}
+ breadcrumbSchema={breadcrumbSchema}
+ title={title}
+ allowComments={true}
+ comments={comments}
+ >
+ {children}
+ </PageLayout>
+ );
+ expect(
+ screen.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
new file mode 100644
index 0000000..f96666e
--- /dev/null
+++ b/src/components/templates/page/page-layout.tsx
@@ -0,0 +1,297 @@
+import Heading from '@components/atoms/headings/heading';
+import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
+import Sidebar from '@components/atoms/layout/sidebar';
+import { MetaData } from '@components/molecules/layout/meta';
+import PageFooter, {
+ type PageFooterProps,
+} from '@components/molecules/layout/page-footer';
+import PageHeader, {
+ type PageHeaderProps,
+} from '@components/molecules/layout/page-header';
+import Breadcrumb, {
+ type BreadcrumbItem,
+} from '@components/molecules/nav/breadcrumb';
+import CommentForm, {
+ type CommentFormProps,
+} from '@components/organisms/forms/comment-form';
+import CommentsList, {
+ type CommentsListProps,
+} from '@components/organisms/layout/comments-list';
+import TableOfContents from '@components/organisms/widgets/table-of-contents';
+import { type SendCommentVars } from '@services/graphql/api';
+import { sendComment } from '@services/graphql/comments';
+import useIsMounted from '@utils/hooks/use-is-mounted';
+import Script from 'next/script';
+import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { BreadcrumbList } from 'schema-dts';
+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: BreadcrumbItem[];
+ /**
+ * 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?: PageFooterProps['meta'];
+ /**
+ * The header metadata.
+ */
+ headerMeta?: PageHeaderProps['meta'];
+ /**
+ * 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.
+ */
+const PageLayout: FC<PageLayoutProps> = ({
+ children,
+ allowComments = false,
+ bodyAttributes,
+ bodyClassName = '',
+ breadcrumb,
+ breadcrumbSchema,
+ comments,
+ footerMeta,
+ headerMeta,
+ id,
+ intro,
+ title,
+ widgets,
+ withToC = false,
+}) => {
+ const intl = useIntl();
+ const commentsTitle = intl.formatMessage({
+ defaultMessage: 'Comments',
+ description: 'PageLayout: comments title',
+ id: '+dJU3e',
+ });
+ const commentFormTitle = intl.formatMessage({
+ defaultMessage: 'Leave a comment',
+ description: 'PageLayout: comment form title',
+ id: 'kzIYoQ',
+ });
+
+ const bodyRef = useRef<HTMLDivElement>(null);
+ const isMounted = useIsMounted(bodyRef);
+ const hasComments = Array.isArray(comments) && comments.length > 0;
+ const [status, setStatus] = useState<NoticeKind>('info');
+ const [statusMessage, setStatusMessage] = useState<string>('');
+ const isReplyRef = useRef<boolean>(false);
+
+ const saveComment: CommentFormProps['saveComment'] = async (data, reset) => {
+ if (!id) throw new Error('Page id missing. Cannot save comment.');
+
+ const { comment: commentBody, email, name, parentId, website } = data;
+ const commentData: SendCommentVars = {
+ author: name,
+ authorEmail: email,
+ authorUrl: website || '',
+ clientMutationId: 'contact',
+ commentOn: id,
+ content: commentBody,
+ parent: parentId,
+ };
+ const { comment, success } = await sendComment(commentData);
+
+ isReplyRef.current = !!parentId;
+
+ if (success) {
+ setStatus('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',
+ });
+ setStatusMessage(`${successPrefix} ${successMessage}`);
+ reset();
+ } else {
+ const error = intl.formatMessage({
+ defaultMessage: 'An error occurred:',
+ description: 'PageLayout: comment form error message',
+ id: 'fkcTGp',
+ });
+ setStatus('error');
+ setStatusMessage(error);
+ }
+ };
+
+ /**
+ * Check if meta properties are defined.
+ *
+ * @param {MetaData} meta - The metadata.
+ */
+ const hasMeta = (meta: MetaData) => {
+ return Object.values(meta).every((value) => value);
+ };
+
+ return (
+ <>
+ <Script
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ />
+ <Breadcrumb
+ items={breadcrumb}
+ className={styles.breadcrumb}
+ itemClassName={styles.breadcrumb__items}
+ />
+ <PageHeader
+ title={title}
+ intro={intro}
+ meta={headerMeta}
+ className={styles.header}
+ />
+ {withToC && (
+ <Sidebar
+ className={`${styles.sidebar} ${styles['sidebar--first']}`}
+ aria-label={intl.formatMessage({
+ defaultMessage: 'Table of contents sidebar',
+ id: 'Q+1GbT',
+ description: 'PageLayout: accessible name for ToC sidebar',
+ })}
+ >
+ {isMounted && bodyRef.current && (
+ <TableOfContents wrapper={bodyRef.current} />
+ )}
+ </Sidebar>
+ )}
+ {typeof children === 'string' ? (
+ <div
+ ref={bodyRef}
+ className={`${styles.body} ${bodyClassName}`}
+ dangerouslySetInnerHTML={{ __html: children }}
+ {...bodyAttributes}
+ />
+ ) : (
+ <div ref={bodyRef} className={`${styles.body} ${bodyClassName}`}>
+ {children}
+ </div>
+ )}
+ {footerMeta && hasMeta(footerMeta) && (
+ <PageFooter meta={footerMeta} className={styles.footer} />
+ )}
+ <Sidebar
+ className={`${styles.sidebar} ${styles['sidebar--last']}`}
+ aria-label={intl.formatMessage({
+ defaultMessage: 'Sidebar',
+ id: 'c556Qo',
+ description: 'PageLayout: accessible name for the sidebar',
+ })}
+ >
+ {widgets}
+ </Sidebar>
+ {allowComments && (
+ <div className={styles.comments} id="comments">
+ <section className={styles.comments__section}>
+ <Heading level={2} alignment="center">
+ {commentsTitle}
+ </Heading>
+ {hasComments ? (
+ <CommentsList
+ comments={comments}
+ depth={2}
+ Notice={
+ isReplyRef.current === true && (
+ <Notice
+ kind={status}
+ message={statusMessage}
+ className={styles.notice}
+ />
+ )
+ }
+ saveComment={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}>
+ <CommentForm
+ className={styles.comments__form}
+ saveComment={saveComment}
+ title={commentFormTitle}
+ titleAlignment="center"
+ Notice={
+ isReplyRef.current === false && (
+ <Notice
+ kind={status}
+ message={statusMessage}
+ className={styles.notice}
+ />
+ )
+ }
+ />
+ </section>
+ </div>
+ )}
+ </>
+ );
+};
+
+export default PageLayout;
diff --git a/src/components/templates/sectioned/sectioned-layout.stories.tsx b/src/components/templates/sectioned/sectioned-layout.stories.tsx
new file mode 100644
index 0000000..689f9a7
--- /dev/null
+++ b/src/components/templates/sectioned/sectioned-layout.stories.tsx
@@ -0,0 +1,80 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
+import SectionedLayoutComponent from './sectioned-layout';
+
+/**
+ * SectionedLayout - Storybook Meta
+ */
+export default {
+ title: 'Templates/Sectioned',
+ component: SectionedLayoutComponent,
+ args: {
+ breadcrumbSchema: [],
+ },
+ argTypes: {
+ breadcrumbSchema: {
+ control: {
+ type: null,
+ },
+ description: 'The JSON schema for breadcrumb items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ sections: {
+ description: 'The different sections.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+ <LayoutBase {...LayoutBase.args}>
+ <Story />
+ </LayoutBase>
+ ),
+ ],
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof SectionedLayoutComponent>;
+
+const Template: ComponentStory<typeof SectionedLayoutComponent> = (args) => (
+ <SectionedLayoutComponent {...args} />
+);
+
+const sections = [
+ {
+ title: 'Section 1',
+ content:
+ 'Qui suscipit ea et aut dicta. Quia ut dignissimos. Sapiente beatae voluptatem quis et. Nemo vitae magni. Nihil iste officia est sed esse molestiae doloribus. Quia temporibus nobis ea fuga quis incidunt doloribus eaque.',
+ },
+ {
+ title: 'Section 2',
+ content:
+ 'Reprehenderit aut magnam ut quos. Voluptatibus beatae et. Earum non atque voluptatum illum rem distinctio repellat.',
+ },
+ {
+ title: 'Section 3',
+ content:
+ 'Placeat rem dolores dolore illum earum officia dolore. Ut est ducimus. Officia eveniet pariatur ut laboriosam voluptatibus aut doloremque natus quis.',
+ },
+ {
+ title: 'Section 4',
+ content:
+ 'Vitae facere ipsa eum sunt debitis veritatis dolorem labore qui. Dolores recusandae omnis aut. Repudiandae quia neque porro in blanditiis. A atque minima fugit. Totam quidem voluptas natus velit at.',
+ },
+];
+
+/**
+ * Sectioned Layout Stories - Default
+ */
+export const Sectioned = Template.bind({});
+Sectioned.args = {
+ sections,
+};
diff --git a/src/components/templates/sectioned/sectioned-layout.test.tsx b/src/components/templates/sectioned/sectioned-layout.test.tsx
new file mode 100644
index 0000000..9b8bab5
--- /dev/null
+++ b/src/components/templates/sectioned/sectioned-layout.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
+import SectionedLayout from './sectioned-layout';
+
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
+const sections = [
+ {
+ title: 'Section 1',
+ content:
+ 'Qui suscipit ea et aut dicta. Quia ut dignissimos. Sapiente beatae voluptatem quis et. Nemo vitae magni. Nihil iste officia est sed esse molestiae doloribus. Quia temporibus nobis ea fuga quis incidunt doloribus eaque.',
+ },
+ {
+ title: 'Section 2',
+ content:
+ 'Reprehenderit aut magnam ut quos. Voluptatibus beatae et. Earum non atque voluptatum illum rem distinctio repellat.',
+ },
+ {
+ title: 'Section 3',
+ content:
+ 'Placeat rem dolores dolore illum earum officia dolore. Ut est ducimus. Officia eveniet pariatur ut laboriosam voluptatibus aut doloremque natus quis.',
+ },
+ {
+ title: 'Section 4',
+ content:
+ 'Vitae facere ipsa eum sunt debitis veritatis dolorem labore qui. Dolores recusandae omnis aut. Repudiandae quia neque porro in blanditiis. A atque minima fugit. Totam quidem voluptas natus velit at.',
+ },
+];
+
+describe('SectionedLayout', () => {
+ it('renders the correct number of section', () => {
+ render(
+ <SectionedLayout
+ breadcrumbSchema={breadcrumbSchema}
+ sections={sections}
+ />
+ );
+ expect(screen.getAllByRole('heading', { name: /^Section/ })).toHaveLength(
+ sections.length
+ );
+ });
+});
diff --git a/src/components/templates/sectioned/sectioned-layout.tsx b/src/components/templates/sectioned/sectioned-layout.tsx
new file mode 100644
index 0000000..f91c354
--- /dev/null
+++ b/src/components/templates/sectioned/sectioned-layout.tsx
@@ -0,0 +1,60 @@
+import Section, {
+ type SectionProps,
+ type SectionVariant,
+} from '@components/atoms/layout/section';
+import Script from 'next/script';
+import { FC } from 'react';
+import { BreadcrumbList } from 'schema-dts';
+
+export type Section = Pick<SectionProps, 'content' | 'title'>;
+
+export type SectionedLayoutProps = {
+ /**
+ * The breadcrumb JSON schema.
+ */
+ breadcrumbSchema: BreadcrumbList['itemListElement'][];
+ /**
+ * An array of objects describing each section.
+ */
+ sections: Section[];
+};
+
+/**
+ * SectionedLayout component
+ *
+ * Render a sectioned layout.
+ */
+const SectionedLayout: FC<SectionedLayoutProps> = ({
+ breadcrumbSchema,
+ sections,
+}) => {
+ const getSections = (items: SectionProps[]) => {
+ return items.map((section, index) => {
+ const variant: SectionVariant = index % 2 ? 'light' : 'dark';
+ const isLastSection = index === items.length - 1;
+
+ return (
+ <Section
+ key={`section-${index}`}
+ title={section.title}
+ content={section.content}
+ variant={variant}
+ withBorder={!isLastSection}
+ />
+ );
+ });
+ };
+
+ return (
+ <>
+ <Script
+ id="schema-breadcrumb"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+ />
+ {getSections(sections)}
+ </>
+ );
+};
+
+export default SectionedLayout;