From d8a5c90ef58fa451c19e8d9e0aab0c493a0a6a9f Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 15 Apr 2022 22:56:36 +0200
Subject: chore: add an ImageWidget component

---
 .../organisms/widgets/image-widget.test.tsx        | 54 ++++++++++++++++++++++
 1 file changed, 54 insertions(+)
 create mode 100644 src/components/organisms/widgets/image-widget.test.tsx

(limited to 'src/components/organisms/widgets/image-widget.test.tsx')

diff --git a/src/components/organisms/widgets/image-widget.test.tsx b/src/components/organisms/widgets/image-widget.test.tsx
new file mode 100644
index 0000000..8c24bd9
--- /dev/null
+++ b/src/components/organisms/widgets/image-widget.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@test-utils';
+import ImageWidget from './image-widget';
+
+const description = 'Ut vitae sit';
+
+const img = {
+  alt: 'Et perferendis quaerat',
+  height: 480,
+  src: 'http://placeimg.com/640/480/nature',
+  width: 640,
+};
+
+const title = 'Fugiat cumque et';
+const titleLevel = 2;
+
+const url = '/another-page';
+
+describe('ImageWidget', () => {
+  it('renders an image', () => {
+    render(
+      <ImageWidget expanded={true} img={img} title={title} level={titleLevel} />
+    );
+    expect(screen.getByRole('img', { name: img.alt })).toBeInTheDocument();
+  });
+
+  it('renders a link', () => {
+    render(
+      <ImageWidget
+        expanded={true}
+        img={img}
+        title={title}
+        level={titleLevel}
+        url={url}
+      />
+    );
+    expect(screen.getByRole('link', { name: img.alt })).toHaveAttribute(
+      'href',
+      url
+    );
+  });
+
+  it('renders a description', () => {
+    render(
+      <ImageWidget
+        expanded={true}
+        img={img}
+        description={description}
+        title={title}
+        level={titleLevel}
+      />
+    );
+    expect(screen.getByText(description)).toBeInTheDocument();
+  });
+});
-- 
cgit v1.2.3


From ee04742d1f0645908baa30e47845126c28848f50 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 3 May 2022 16:53:55 +0200
Subject: chore: add a CV page

---
 src/components/atoms/links/link.module.scss        |  87 ++++++++-
 src/components/atoms/links/link.stories.tsx        |  20 ++-
 src/components/atoms/links/link.tsx                |  18 +-
 .../molecules/images/responsive-image.module.scss  |  11 +-
 .../molecules/images/responsive-image.stories.tsx  |   1 +
 .../molecules/images/responsive-image.tsx          |   6 +-
 .../organisms/widgets/image-widget.module.scss     |  13 +-
 .../organisms/widgets/image-widget.stories.tsx     |  25 ++-
 .../organisms/widgets/image-widget.test.tsx        |  11 +-
 src/components/organisms/widgets/image-widget.tsx  |  18 +-
 src/pages/cv.tsx                                   | 198 +++++++++++++++++++++
 src/styles/pages/cv.module.scss                    |   3 +
 12 files changed, 375 insertions(+), 36 deletions(-)
 create mode 100644 src/pages/cv.tsx
 create mode 100644 src/styles/pages/cv.module.scss

(limited to 'src/components/organisms/widgets/image-widget.test.tsx')

diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
index d23667a..1b89727 100644
--- a/src/components/atoms/links/link.module.scss
+++ b/src/components/atoms/links/link.module.scss
@@ -5,23 +5,64 @@
   &[hreflang] {
     &::after {
       display: inline-block;
+
       /* Prettier is removing spacing between content parts. */
+
       /* prettier-ignore */
       content: "\0000a0[" attr(hreflang) "]";
       font-size: var(--font-size-sm);
     }
   }
 
+  &--download {
+    &::after {
+      display: inline-block;
+
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(
+fun.encode-svg(
+        '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'
+));
+    }
+
+    &:focus:not(:active)::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(
+fun.encode-svg(
+        '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'
+));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(
+fun.encode-svg(
+          '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'
+));
+      }
+    }
+  }
+
   &--external {
     &::after {
       display: inline-block;
+
       /* Prettier is removing spacing between content parts. */
+
       /* prettier-ignore */
       content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
     }
 
     &:focus:not(:active)::after {
       /* Prettier is removing spacing between content parts. */
+
       /* prettier-ignore */
       content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
     }
@@ -29,18 +70,56 @@
     &[hreflang] {
       &::after {
         /* Prettier is removing spacing between content parts. */
+
         /* prettier-ignore */
-        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg(
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(
+fun.encode-svg(
             '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
-          ));
+));
       }
 
       &:focus:not(:active)::after {
         /* Prettier is removing spacing between content parts. */
+
         /* prettier-ignore */
-        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg(
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(
+fun.encode-svg(
             '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'
-          ));
+));
+      }
+    }
+  }
+
+  &--external#{&}--download {
+    &::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(
+fun.encode-svg(
+          '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'
+)) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(
+fun.encode-svg(
+          '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'
+)) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+
+      &:focus:not(:active)::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(
+fun.encode-svg(
+          '<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'
+)) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
       }
     }
   }
diff --git a/src/components/atoms/links/link.stories.tsx b/src/components/atoms/links/link.stories.tsx
index c3b3465..420eafe 100644
--- a/src/components/atoms/links/link.stories.tsx
+++ b/src/components/atoms/links/link.stories.tsx
@@ -31,14 +31,29 @@ export default {
         required: false,
       },
     },
-    external: {
+    download: {
       control: {
         type: 'boolean',
       },
+      description: 'Determine if the link purpose is to download a file.',
       table: {
         category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+    external: {
+      control: {
+        type: 'boolean',
       },
       description: 'Determine if the link is external of the current website.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
       type: {
         name: 'boolean',
         required: false,
@@ -79,6 +94,7 @@ export const Default = Template.bind({});
 Default.args = {
   children: 'A link',
   href: '#',
+  download: false,
   external: false,
 };
 
@@ -89,6 +105,7 @@ export const External = Template.bind({});
 External.args = {
   children: 'A link',
   href: '#',
+  download: false,
   external: true,
 };
 
@@ -99,6 +116,7 @@ export const ExternalWithLang = Template.bind({});
 ExternalWithLang.args = {
   children: 'A link',
   href: '#',
+  download: false,
   external: true,
   lang: 'en',
 };
diff --git a/src/components/atoms/links/link.tsx b/src/components/atoms/links/link.tsx
index 674c07b..c8ba273 100644
--- a/src/components/atoms/links/link.tsx
+++ b/src/components/atoms/links/link.tsx
@@ -11,6 +11,10 @@ export type LinkProps = {
    * Set additional classnames to the link.
    */
   className?: string;
+  /**
+   * True if it is a download link. Default: false.
+   */
+  download?: boolean;
   /**
    * True if it is an external link. Default: false.
    */
@@ -33,21 +37,29 @@ export type LinkProps = {
 const Link: FC<LinkProps> = ({
   children,
   className = '',
+  download = false,
+  external = false,
   href,
   lang,
-  external = false,
 }) => {
+  const downloadClass = download ? styles['link--download'] : '';
+
   return external ? (
     <a
       href={href}
       hrefLang={lang}
-      className={`${styles.link} ${styles['link--external']} ${className}`}
+      className={`${styles.link} ${styles['link--external']} ${downloadClass} ${className}`}
     >
       {children}
     </a>
   ) : (
     <NextLink href={href}>
-      <a className={`${styles.link} ${className}`}>{children}</a>
+      <a
+        hrefLang={lang}
+        className={`${styles.link} ${downloadClass} ${className}`}
+      >
+        {children}
+      </a>
     </NextLink>
   );
 };
diff --git a/src/components/molecules/images/responsive-image.module.scss b/src/components/molecules/images/responsive-image.module.scss
index 3566421..3a4f283 100644
--- a/src/components/molecules/images/responsive-image.module.scss
+++ b/src/components/molecules/images/responsive-image.module.scss
@@ -3,8 +3,7 @@
 .wrapper {
   display: flex;
   flex-flow: column;
-  width: 100%;
-  max-width: max-content;
+  width: fit-content;
   margin: 0;
   position: relative;
   text-align: center;
@@ -14,9 +13,7 @@
   margin: 0;
   padding: fun.convert-px(4) var(--spacing-2xs);
   background: var(--color-bg-secondary);
-  border: fun.convert-px(1) solid var(--color-border);
-  box-shadow: 0 fun.convert-px(-1) fun.convert-px(1) fun.convert-px(1)
-    var(--color-shadow-light);
+  border: fun.convert-px(1) solid var(--color-border-light);
   font-weight: 500;
 }
 
@@ -32,7 +29,7 @@
 
   &:hover,
   &:focus {
-    transform: scale(1.1);
+    transform: scale(var(--scale-up, 1.05));
   }
 
   &:focus {
@@ -43,7 +40,7 @@
   }
 
   &:active {
-    transform: scale(0.9);
+    transform: scale(var(--scale-down, 0.95));
 
     .caption {
       text-decoration: none;
diff --git a/src/components/molecules/images/responsive-image.stories.tsx b/src/components/molecules/images/responsive-image.stories.tsx
index a1f5295..35d9116 100644
--- a/src/components/molecules/images/responsive-image.stories.tsx
+++ b/src/components/molecules/images/responsive-image.stories.tsx
@@ -64,6 +64,7 @@ export default {
         required: false,
       },
     },
+    unoptimized: { table: { disable: true } },
     width: {
       control: {
         type: 'number',
diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx
index 31cbcd1..d07dd6c 100644
--- a/src/components/molecules/images/responsive-image.tsx
+++ b/src/components/molecules/images/responsive-image.tsx
@@ -1,6 +1,6 @@
 import Link, { type LinkProps } from '@components/atoms/links/link';
 import Image, { type ImageProps } from 'next/image';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './responsive-image.module.scss';
 
 export type ResponsiveImageProps = Omit<
@@ -14,7 +14,7 @@ export type ResponsiveImageProps = Omit<
   /**
    * A figure caption.
    */
-  caption?: string;
+  caption?: ReactNode;
   /**
    * Set additional classnames to the figure wrapper.
    */
@@ -55,6 +55,7 @@ const ResponsiveImage: FC<ResponsiveImageProps> = ({
             alt={alt}
             layout={layout || 'intrinsic'}
             objectFit={objectFit || 'contain'}
+            className={styles.img}
             {...props}
           />
           {caption && (
@@ -67,6 +68,7 @@ const ResponsiveImage: FC<ResponsiveImageProps> = ({
             alt={alt}
             layout={layout || 'intrinsic'}
             objectFit={objectFit || 'contain'}
+            className={styles.img}
             {...props}
           />
           {caption && (
diff --git a/src/components/organisms/widgets/image-widget.module.scss b/src/components/organisms/widgets/image-widget.module.scss
index 8e4d0aa..0d69441 100644
--- a/src/components/organisms/widgets/image-widget.module.scss
+++ b/src/components/organisms/widgets/image-widget.module.scss
@@ -1,7 +1,12 @@
 @use "@styles/abstracts/functions" as fun;
 
-.img {
+.figure {
+  --scale-up: 1.02;
+  --scale-down: 0.98;
+
   margin: 0;
+  padding: fun.convert-px(5);
+  border: fun.convert-px(1) solid var(--color-border);
 }
 
 .txt {
@@ -10,7 +15,7 @@
 
 .widget {
   &--left {
-    .img {
+    .figure {
       margin-right: auto;
     }
 
@@ -20,7 +25,7 @@
   }
 
   &--center {
-    .img {
+    .figure {
       margin-left: auto;
       margin-right: auto;
     }
@@ -31,7 +36,7 @@
   }
 
   &--right {
-    .img {
+    .figure {
       margin-left: auto;
     }
 
diff --git a/src/components/organisms/widgets/image-widget.stories.tsx b/src/components/organisms/widgets/image-widget.stories.tsx
index 27871ae..3014b36 100644
--- a/src/components/organisms/widgets/image-widget.stories.tsx
+++ b/src/components/organisms/widgets/image-widget.stories.tsx
@@ -50,7 +50,7 @@ export default {
         required: true,
       },
     },
-    img: {
+    image: {
       description: 'An image object.',
       type: {
         name: 'object',
@@ -58,6 +58,19 @@ export default {
         value: {},
       },
     },
+    imageClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the image wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     level: {
       control: {
         type: 'number',
@@ -107,7 +120,7 @@ const Template: ComponentStory<typeof ImageWidget> = (args) => (
   <ImageWidget {...args} />
 );
 
-const img = {
+const image = {
   alt: 'Et perferendis quaerat',
   height: 480,
   src: 'http://placeimg.com/640/480/nature',
@@ -122,7 +135,7 @@ export const AlignLeft = Template.bind({});
 AlignLeft.args = {
   alignment: 'left',
   expanded: true,
-  img,
+  image,
   level: 2,
   title: 'Quo et totam',
 };
@@ -134,7 +147,7 @@ export const AlignCenter = Template.bind({});
 AlignCenter.args = {
   alignment: 'center',
   expanded: true,
-  img,
+  image,
   level: 2,
   title: 'Quo et totam',
 };
@@ -146,7 +159,7 @@ export const AlignRight = Template.bind({});
 AlignRight.args = {
   alignment: 'right',
   expanded: true,
-  img,
+  image,
   level: 2,
   title: 'Quo et totam',
 };
@@ -158,7 +171,7 @@ export const WithDescription = Template.bind({});
 WithDescription.args = {
   description: 'Sint enim harum',
   expanded: true,
-  img,
+  image,
   level: 2,
   title: 'Quo et totam',
 };
diff --git a/src/components/organisms/widgets/image-widget.test.tsx b/src/components/organisms/widgets/image-widget.test.tsx
index 8c24bd9..c6b1a3a 100644
--- a/src/components/organisms/widgets/image-widget.test.tsx
+++ b/src/components/organisms/widgets/image-widget.test.tsx
@@ -18,7 +18,12 @@ const url = '/another-page';
 describe('ImageWidget', () => {
   it('renders an image', () => {
     render(
-      <ImageWidget expanded={true} img={img} title={title} level={titleLevel} />
+      <ImageWidget
+        expanded={true}
+        image={img}
+        title={title}
+        level={titleLevel}
+      />
     );
     expect(screen.getByRole('img', { name: img.alt })).toBeInTheDocument();
   });
@@ -27,7 +32,7 @@ describe('ImageWidget', () => {
     render(
       <ImageWidget
         expanded={true}
-        img={img}
+        image={img}
         title={title}
         level={titleLevel}
         url={url}
@@ -43,7 +48,7 @@ describe('ImageWidget', () => {
     render(
       <ImageWidget
         expanded={true}
-        img={img}
+        image={img}
         description={description}
         title={title}
         level={titleLevel}
diff --git a/src/components/organisms/widgets/image-widget.tsx b/src/components/organisms/widgets/image-widget.tsx
index ba04c6a..873337b 100644
--- a/src/components/organisms/widgets/image-widget.tsx
+++ b/src/components/organisms/widgets/image-widget.tsx
@@ -14,7 +14,7 @@ export type Image = Pick<
 
 export type ImageWidgetProps = Pick<
   WidgetProps,
-  'expanded' | 'level' | 'title'
+  'className' | 'expanded' | 'level' | 'title'
 > & {
   /**
    * The content alignment.
@@ -27,7 +27,11 @@ export type ImageWidgetProps = Pick<
   /**
    * An object describing the image.
    */
-  img: Image;
+  image: Image;
+  /**
+   * Set additional classnames to the image wrapper.
+   */
+  imageClassName?: string;
   /**
    * Add a link to the image.
    */
@@ -41,20 +45,22 @@ export type ImageWidgetProps = Pick<
  */
 const ImageWidget: FC<ImageWidgetProps> = ({
   alignment = 'left',
+  className = '',
   description,
-  img,
+  image,
+  imageClassName = '',
   url,
   ...props
 }) => {
   const alignmentClass = `widget--${alignment}`;
 
   return (
-    <Widget className={styles[alignmentClass]} {...props}>
+    <Widget className={`${styles[alignmentClass]} ${className}`} {...props}>
       <ResponsiveImage
         target={url}
         caption={description}
-        className={styles.img}
-        {...img}
+        className={`${styles.figure} ${imageClassName}`}
+        {...image}
       />
     </Widget>
   );
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
new file mode 100644
index 0000000..d47edc6
--- /dev/null
+++ b/src/pages/cv.tsx
@@ -0,0 +1,198 @@
+import Link from '@components/atoms/links/link';
+import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import ImageWidget from '@components/organisms/widgets/image-widget';
+import SocialMedia from '@components/organisms/widgets/social-media';
+import PageLayout, {
+  type PageLayoutProps,
+} from '@components/templates/page/page-layout';
+import CVContent, { data, meta } from '@content/pages/cv.mdx';
+import styles from '@styles/pages/cv.module.scss';
+import { getFormattedDate } from '@utils/helpers/dates';
+import { loadTranslation } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+import { GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { ReactNode } from 'react';
+import { useIntl } from 'react-intl';
+import { AboutPage, Graph, WebPage } from 'schema-dts';
+
+/**
+ * CV page.
+ */
+const CVPage: NextPage = () => {
+  const intl = useIntl();
+  const { file, image } = data;
+  const { dates, intro, seo, title } = meta;
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'cv', name: title, url: '/cv' },
+  ];
+
+  const imageWidgetTitle = intl.formatMessage({
+    defaultMessage: 'Others formats',
+    description: 'CVPage: cv preview widget title',
+    id: 'B9OCyV',
+  });
+  const socialMediaTitle = intl.formatMessage({
+    defaultMessage: 'Open-source projects',
+    description: 'CVPage: social media widget title',
+    id: '+Dre5J',
+  });
+
+  const publicationLabel = intl.formatMessage({
+    defaultMessage: 'Published on:',
+    description: 'Meta: publication date label',
+    id: 'QGi5uD',
+  });
+
+  const updateLabel = intl.formatMessage({
+    defaultMessage: 'Updated on:',
+    description: 'Meta: update date label',
+    id: 'tLC7bh',
+  });
+
+  const headerMeta: PageLayoutProps['headerMeta'] = {
+    publication: {
+      name: publicationLabel,
+      value: getFormattedDate(dates.publication),
+    },
+    update: { name: updateLabel, value: getFormattedDate(dates.update) },
+  };
+
+  const { website } = useSettings();
+  const cvAlt = intl.formatMessage(
+    {
+      defaultMessage: '{name} CV',
+      description: 'CVPage: CV image alternative text',
+      id: 'KUowUk',
+    },
+    { name: website.name }
+  );
+  const cvCaption = intl.formatMessage(
+    {
+      defaultMessage: '<link>Download the CV in PDF</link>',
+      id: 'fN04AJ',
+      description: 'CVPage: download CV in PDF text',
+    },
+    {
+      link: (chunks: ReactNode) => (
+        <Link download={true} href={file}>
+          {chunks}
+        </Link>
+      ),
+    }
+  );
+
+  const widgets = [
+    <ImageWidget
+      key="image-widget"
+      expanded={true}
+      title={imageWidgetTitle}
+      level={2}
+      image={{ alt: cvAlt, ...image }}
+      description={cvCaption}
+      imageClassName={styles.image}
+    />,
+    <SocialMedia
+      key="social-media"
+      title={socialMediaTitle}
+      level={2}
+      media={[
+        { name: 'Github', url: 'https://github.com/ArmandPhilippot' },
+        { name: 'Gitlab', url: 'https://gitlab.com/ArmandPhilippot' },
+        {
+          name: 'LinkedIn',
+          url: 'https://www.linkedin.com/in/armandphilippot',
+        },
+      ]}
+    />,
+  ];
+
+  const { asPath } = useRouter();
+  const pageUrl = `${website.url}${asPath}`;
+  const pagePublicationDate = new Date(dates.publication);
+  const pageUpdateDate = new Date(dates.update);
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    name: seo.title,
+    description: seo.description,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${pageUrl}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const cvSchema: AboutPage = {
+    '@id': `${website.url}/#cv`,
+    '@type': 'AboutPage',
+    name: seo.title,
+    description: intro,
+    author: { '@id': `${website.url}/#branding` },
+    creator: { '@id': `${website.url}/#branding` },
+    dateCreated: pagePublicationDate.toISOString(),
+    dateModified: pageUpdateDate.toISOString(),
+    datePublished: pagePublicationDate.toISOString(),
+    editor: { '@id': `${website.url}/#branding` },
+    image: image.src,
+    inLanguage: website.locales.default,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    thumbnailUrl: image.src,
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, cvSchema],
+  };
+
+  return (
+    <PageLayout
+      title={title}
+      intro={intro}
+      headerMeta={headerMeta}
+      breadcrumb={breadcrumb}
+      withToC={true}
+      widgets={widgets}
+    >
+      <Head>
+        <title>{`${seo.title} - ${website.name}`}</title>
+        <meta name="description" content={seo.description} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="article" />
+        <meta property="og:title" content={title} />
+        <meta property="og:description" content={intro} />
+        <meta property="og:image" content={image.src} />
+        <meta property="og:image:alt" content={title} />
+      </Head>
+      <Script
+        id="schema-cv"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <CVContent />
+    </PageLayout>
+  );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      translation,
+    },
+  };
+};
+
+export default CVPage;
diff --git a/src/styles/pages/cv.module.scss b/src/styles/pages/cv.module.scss
new file mode 100644
index 0000000..615c50d
--- /dev/null
+++ b/src/styles/pages/cv.module.scss
@@ -0,0 +1,3 @@
+.image {
+  max-width: min(100%, 25ch);
+}
-- 
cgit v1.2.3

