aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-02 18:57:29 +0200
committerArmand Philippot <git@armandphilippot.com>2022-05-03 15:22:24 +0200
commit732d0943f8041d76262222a092b014f2557085ef (patch)
tree16f6f76648b479a9591400ab15bb3e9c914f2226 /src
parentca921d7536cfe950b5a7d442977bbf900b48faf4 (diff)
chore: add homepage
Diffstat (limited to 'src')
-rw-r--r--src/components/atoms/lists/list.module.scss16
-rw-r--r--src/components/atoms/lists/list.stories.tsx13
-rw-r--r--src/components/atoms/lists/list.tsx4
-rw-r--r--src/components/molecules/layout/card.module.scss3
-rw-r--r--src/components/molecules/layout/card.tsx2
-rw-r--r--src/components/molecules/layout/page-header.tsx2
-rw-r--r--src/components/molecules/nav/nav.stories.tsx26
-rw-r--r--src/components/molecules/nav/nav.tsx7
-rw-r--r--src/components/organisms/layout/cards-list.module.scss4
-rw-r--r--src/components/organisms/layout/cards-list.stories.tsx13
-rw-r--r--src/components/organisms/layout/cards-list.tsx8
-rw-r--r--src/components/templates/layout/layout.module.scss9
-rw-r--r--src/components/templates/layout/layout.stories.tsx10
-rw-r--r--src/components/templates/layout/layout.tsx107
-rw-r--r--src/pages/index.tsx365
-rw-r--r--src/services/graphql/articles.ts52
-rw-r--r--src/styles/pages/Home.module.scss49
-rw-r--r--src/styles/pages/home.module.scss36
-rw-r--r--src/utils/hooks/use-settings.tsx112
19 files changed, 745 insertions, 93 deletions
diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss
index df3b49c..f647072 100644
--- a/src/components/atoms/lists/list.module.scss
+++ b/src/components/atoms/lists/list.module.scss
@@ -1,3 +1,5 @@
+@use "@styles/abstracts/placeholders";
+
.list {
margin: 0;
@@ -36,4 +38,18 @@
margin-bottom: var(--spacing-2xs);
}
}
+
+ &--flex {
+ @extend %reset-list;
+
+ display: flex;
+ flex-flow: row wrap;
+ gap: var(--spacing-sm);
+ }
+
+ &--flex &--flex {
+ display: initial;
+ position: relative;
+ top: var(--spacing-2xs);
+ }
}
diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx
index 3a80962..54fdd3a 100644
--- a/src/components/atoms/lists/list.stories.tsx
+++ b/src/components/atoms/lists/list.stories.tsx
@@ -39,8 +39,8 @@ export default {
control: {
type: 'select',
},
- description: 'The list kind: ordered or unordered.',
- options: ['ordered', 'unordered'],
+ description: 'The list kind: flex, ordered or unordered.',
+ options: ['flex', 'ordered', 'unordered'],
table: {
category: 'Options',
defaultValue: { summary: 'unordered' },
@@ -72,6 +72,15 @@ const items: ListItem[] = [
];
/**
+ * List Stories - Flex list
+ */
+export const Flex = Template.bind({});
+Flex.args = {
+ items,
+ kind: 'flex',
+};
+
+/**
* List Stories - Ordered list
*/
export const Ordered = Template.bind({});
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
index 6726802..711ade1 100644
--- a/src/components/atoms/lists/list.tsx
+++ b/src/components/atoms/lists/list.tsx
@@ -30,9 +30,9 @@ export type ListProps = {
*/
itemsClassName?: string;
/**
- * The list kind (ordered or unordered).
+ * The list kind.
*/
- kind?: 'ordered' | 'unordered';
+ kind?: 'ordered' | 'unordered' | 'flex';
/**
* Set margin between list items. Default: true.
*/
diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss
index 85c319a..d5b9836 100644
--- a/src/components/molecules/layout/card.module.scss
+++ b/src/components/molecules/layout/card.module.scss
@@ -19,7 +19,8 @@
.cover {
align-self: flex-start;
- max-height: fun.convert-px(150);
+ place-content: center;
+ height: fun.convert-px(150);
margin: auto;
border-bottom: fun.convert-px(1) solid var(--color-border);
}
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index 15927e9..89f100e 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -93,7 +93,7 @@ const Card: FC<CardProps> = ({
{title}
</Heading>
</header>
- {tagline && <div className={styles.tagline}>{tagline}</div>}
+ <div className={styles.tagline}>{tagline}</div>
{meta && (
<footer className={styles.footer}>
<DescriptionList
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
index 174e246..1663085 100644
--- a/src/components/molecules/layout/page-header.tsx
+++ b/src/components/molecules/layout/page-header.tsx
@@ -11,7 +11,7 @@ export type PageHeaderProps = {
/**
* The page introduction.
*/
- intro?: string;
+ intro?: string | JSX.Element;
/**
* The page metadata.
*/
diff --git a/src/components/molecules/nav/nav.stories.tsx b/src/components/molecules/nav/nav.stories.tsx
index 25455fd..5cef5f0 100644
--- a/src/components/molecules/nav/nav.stories.tsx
+++ b/src/components/molecules/nav/nav.stories.tsx
@@ -11,6 +11,19 @@ export default {
title: 'Molecules/Navigation/Nav',
component: NavComponent,
argTypes: {
+ 'aria-label': {
+ control: {
+ type: 'text',
+ },
+ description: 'An accessible name for the navigation.',
+ table: {
+ category: 'Accessibility',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
className: {
control: {
type: 'text',
@@ -46,6 +59,19 @@ export default {
required: true,
},
},
+ listClassName: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the navigation list.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
},
decorators: [
(Story) => (
diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx
index 2666ea2..581f813 100644
--- a/src/components/molecules/nav/nav.tsx
+++ b/src/components/molecules/nav/nav.tsx
@@ -24,6 +24,10 @@ export type NavItem = {
export type NavProps = {
/**
+ * An accessible name.
+ */
+ 'aria-label'?: string;
+ /**
* Set additional classnames to the navigation wrapper.
*/
className?: string;
@@ -51,6 +55,7 @@ const Nav: FC<NavProps> = ({
items,
kind,
listClassName = '',
+ ...props
}) => {
const kindClass = `nav--${kind}`;
@@ -71,7 +76,7 @@ const Nav: FC<NavProps> = ({
};
return (
- <nav className={`${styles[kindClass]} ${className}`}>
+ <nav className={`${styles[kindClass]} ${className}`} {...props}>
<ul className={`${styles.nav__list} ${listClassName}`}>{getItems()}</ul>
</nav>
);
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
index 9fe428c..2763585 100644
--- a/src/components/organisms/layout/cards-list.module.scss
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -1,12 +1,10 @@
@use "@styles/abstracts/placeholders";
.wrapper {
- --card-width: 30ch;
-
display: grid;
grid-template-columns: repeat(
auto-fit,
- min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width))
+ min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width, 30ch))
);
gap: var(--spacing-sm);
place-content: center;
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
index 7ff4365..fe0ebfd 100644
--- a/src/components/organisms/layout/cards-list.stories.tsx
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -12,6 +12,19 @@ export default {
kind: 'unordered',
},
argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
coverFit: {
control: {
type: 'select',
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 33ffe23..1558d7c 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -15,6 +15,10 @@ export type CardsListItem = Omit<
export type CardsListProps = {
/**
+ * Set additional classnames to the list wrapper.
+ */
+ className?: string;
+ /**
* The cover fit.
*/
coverFit?: CardProps['coverFit'];
@@ -38,6 +42,7 @@ export type CardsListProps = {
* Return a list of Card components.
*/
const CardsList: FC<CardsListProps> = ({
+ className = '',
coverFit,
items,
kind = 'unordered',
@@ -70,9 +75,10 @@ const CardsList: FC<CardsListProps> = ({
return (
<List
+ kind="flex"
items={getCards(items)}
withMargin={false}
- className={`${styles.wrapper} ${styles[kindModifier]}`}
+ className={`${styles.wrapper} ${styles[kindModifier]} ${className}`}
/>
);
};
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 3533257..806d2d7 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -1,15 +1,6 @@
@use "@styles/abstracts/functions" as fun;
@use "@styles/abstracts/mixins" as mix;
-:global {
- #__next {
- flex: 1;
- display: flex;
- flex-flow: column nowrap;
- min-height: 100vh;
- }
-}
-
.header {
border-bottom: fun.convert-px(3) solid var(--color-border-light);
}
diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
index f3579e3..2415412 100644
--- a/src/components/templates/layout/layout.stories.tsx
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -36,7 +36,15 @@ export default {
decorators: [
(Story) => (
<IntlProvider locale="en">
- <div id="__next">
+ <div
+ id="__next"
+ style={{
+ flex: 1,
+ display: 'flex',
+ flexFlow: 'column nowrap',
+ minHeight: '100vh',
+ }}
+ >
<Story />
</div>
</IntlProvider>
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 601ced4..e1be1af 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -1,4 +1,3 @@
-import photo from '@assets/images/armand-philippot.jpg';
import ButtonLink from '@components/atoms/buttons/button-link';
import Career from '@components/atoms/icons/career';
import CCBySA from '@components/atoms/icons/cc-by-sa';
@@ -8,13 +7,19 @@ 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 from '@components/organisms/layout/footer';
-import Header, { HeaderProps } from '@components/organisms/layout/header';
-import { settings } from '@utils/config';
+import Footer, { FooterProps } from '@components/organisms/layout/footer';
+import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import useSettings from '@utils/hooks/use-settings';
+import Script from 'next/script';
import { FC, ReactNode } 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.
@@ -33,6 +38,9 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
*/
const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
const intl = useIntl();
+ const { website } = useSettings();
+ const { baseline, copyright, locales, name, picture, url } = website;
+
const skipToContent = intl.formatMessage({
defaultMessage: 'Skip to content',
description: 'Layout: Skip to content link',
@@ -45,12 +53,12 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
id: '7jVUT6',
});
- const copyright = {
+ const copyrightData = {
dates: {
- start: settings.copyright.startYear,
- end: settings.copyright.endYear,
+ start: copyright.start,
+ end: copyright.end,
},
- owner: settings.name,
+ owner: name,
icon: <CCBySA />,
};
@@ -80,21 +88,77 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
id: 'AE4kCD',
});
- const nav: HeaderProps['nav'] = [
- { id: 'home', label: homeLabel, href: '#', logo: <Home /> },
- { id: 'blog', label: blogLabel, href: '#', logo: <PostsStack /> },
+ const mainNav: HeaderProps['nav'] = [
+ { id: 'home', label: homeLabel, href: '/', logo: <Home /> },
+ { id: 'blog', label: blogLabel, href: '/blog', logo: <PostsStack /> },
{
id: 'projects',
label: projectsLabel,
- href: '#',
+ href: '/projets',
logo: <ComputerScreen />,
},
- { id: 'cv', label: cvLabel, href: '#', logo: <Career /> },
- { id: 'contact', label: contactLabel, href: '#', logo: <Envelop /> },
+ { 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}` },
+ };
+
return (
<>
+ <Script
+ id="schema-layout"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+ ></Script>
+ <Script
+ id="schema-branding"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
+ />
<noscript>
<div className={styles['noscript-spacing']}></div>
</noscript>
@@ -103,10 +167,10 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
{skipToContent}
</ButtonLink>
<Header
- title={settings.name}
- baseline={settings.baseline.fr}
- photo={photo.src}
- nav={nav}
+ title={name}
+ baseline={baseline}
+ photo={picture}
+ nav={mainNav}
isHome={isHome}
className={styles.header}
withLink={true}
@@ -114,7 +178,12 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
<Main id="main" className={styles.main}>
<article {...props}>{children}</article>
</Main>
- <Footer copyright={copyright} topId="top" className={styles.footer} />
+ <Footer
+ copyright={copyrightData}
+ navItems={footerNav}
+ topId="top"
+ className={styles.footer}
+ />
<noscript>
<NoScript message={noScript} position="top" />
</noscript>
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..c965320
--- /dev/null
+++ b/src/pages/index.tsx
@@ -0,0 +1,365 @@
+import FeedIcon from '@assets/images/icon-feed.svg';
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Envelop from '@components/atoms/icons/envelop';
+import Column, { type ColumnProps } from '@components/atoms/layout/column';
+import Section, { type SectionProps } from '@components/atoms/layout/section';
+import List, { type ListItem } from '@components/atoms/lists/list';
+import ResponsiveImage, {
+ type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Columns, {
+ type ColumnsProps,
+} from '@components/molecules/layout/columns';
+import CardsList, {
+ type CardsListItem,
+} from '@components/organisms/layout/cards-list';
+import Layout from '@components/templates/layout/layout';
+import HomePageContent from '@content/pages/homepage.mdx';
+import { getArticlesCard } from '@services/graphql/articles';
+import styles from '@styles/pages/home.module.scss';
+import { ArticleCard } from '@ts/types/app';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+import { NestedMDXComponents } from 'mdx/types';
+import { GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import Script from 'next/script';
+import { ReactElement } from 'react';
+import { useIntl } from 'react-intl';
+import { Graph, WebPage } from 'schema-dts';
+
+type HomeProps = {
+ recentPosts: ArticleCard[];
+ translation?: Messages;
+};
+
+/**
+ * Home page.
+ */
+const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
+ const intl = useIntl();
+
+ /**
+ * Retrieve a list of coding links.
+ *
+ * @returns {JSX.Element} - A list of links.
+ */
+ const CodingLinks = (): JSX.Element => {
+ const links: ListItem[] = [
+ {
+ id: 'web-development',
+ value: (
+ <ButtonLink target="/thematique/developpement-web">
+ {intl.formatMessage({
+ defaultMessage: 'Web development',
+ description: 'HomePage: link to web development thematic',
+ id: 'vkF/RP',
+ })}
+ </ButtonLink>
+ ),
+ },
+ {
+ id: 'projects',
+ value: (
+ <ButtonLink target="/projets">
+ {intl.formatMessage({
+ defaultMessage: 'Projects',
+ description: 'HomePage: link to projects',
+ id: 'N44SOc',
+ })}
+ </ButtonLink>
+ ),
+ },
+ ];
+
+ return <List kind="flex" items={links} className={styles.list} />;
+ };
+
+ /**
+ * Retrieve a list of Coldark repositories.
+ *
+ * @returns {JSX.Element} - A list of links.
+ */
+ const ColdarkRepos = (): JSX.Element => {
+ const links: ListItem[] = [
+ {
+ id: 'coldark-github',
+ value: (
+ <ButtonLink
+ target="https://github.com/ArmandPhilippot/coldark"
+ external={true}
+ >
+ {intl.formatMessage({
+ defaultMessage: 'Github',
+ description: 'HomePage: Github link',
+ id: '3f3PzH',
+ })}
+ </ButtonLink>
+ ),
+ },
+ {
+ id: 'coldark-gitlab',
+ value: (
+ <ButtonLink
+ target="https://gitlab.com/ArmandPhilippot/coldark"
+ external={true}
+ >
+ {intl.formatMessage({
+ defaultMessage: 'Gitlab',
+ description: 'HomePage: Gitlab link',
+ id: '7AnwZ7',
+ })}
+ </ButtonLink>
+ ),
+ },
+ ];
+
+ return <List kind="flex" items={links} className={styles.list} />;
+ };
+
+ /**
+ * Retrieve a list of links related to Free thematic.
+ *
+ * @returns {JSX.Element} - A list of links.
+ */
+ const LibreLinks = (): JSX.Element => {
+ const links: ListItem[] = [
+ {
+ id: 'free',
+ value: (
+ <ButtonLink target="/thematique/libre">
+ {intl.formatMessage({
+ defaultMessage: 'Free',
+ description: 'HomePage: link to free thematic',
+ id: 'w8GrOf',
+ })}
+ </ButtonLink>
+ ),
+ },
+ {
+ id: 'linux',
+ value: (
+ <ButtonLink target="/thematique/linux">
+ {intl.formatMessage({
+ defaultMessage: 'Linux',
+ description: 'HomePage: link to Linux thematic',
+ id: 'jASD7k',
+ })}
+ </ButtonLink>
+ ),
+ },
+ ];
+
+ return <List kind="flex" items={links} className={styles.list} />;
+ };
+
+ /**
+ * Retrieve the Shaarli link.
+ *
+ * @returns {JSX.Element} - A list of links
+ */
+ const ShaarliLink = (): JSX.Element => {
+ const links: ListItem[] = [
+ {
+ id: 'shaarli',
+ value: (
+ <ButtonLink target="https://shaarli.armandphilippot.com/">
+ {intl.formatMessage({
+ defaultMessage: 'Shaarli',
+ description: 'HomePage: link to Shaarli',
+ id: 'i5L19t',
+ })}
+ </ButtonLink>
+ ),
+ },
+ ];
+
+ return <List kind="flex" items={links} className={styles.list} />;
+ };
+
+ /**
+ * Retrieve the additional links.
+ *
+ * @returns {JSX.Element} - A list of links.
+ */
+ const MoreLinks = (): JSX.Element => {
+ const links: ListItem[] = [
+ {
+ id: 'contact-me',
+ value: (
+ <ButtonLink target="/contact">
+ <Envelop className={styles.icon} />
+ {intl.formatMessage({
+ defaultMessage: 'Contact me',
+ description: 'HomePage: contact button text',
+ id: 'sO/Iwj',
+ })}
+ </ButtonLink>
+ ),
+ },
+ {
+ id: 'rss-feed',
+ value: (
+ <ButtonLink target="/feed">
+ <FeedIcon className={`${styles.icon} ${styles['icon--feed']}`} />
+ {intl.formatMessage({
+ defaultMessage: 'Subscribe',
+ description: 'HomePage: RSS feed subscription text',
+ id: 'T4YA64',
+ })}
+ </ButtonLink>
+ ),
+ },
+ ];
+
+ return <List kind="flex" items={links} className={styles.list} />;
+ };
+
+ /**
+ * Get a cards list of recent posts.
+ *
+ * @returns {JSX.Element} - The cards list.
+ */
+ const getRecentPosts = (): JSX.Element => {
+ const posts: CardsListItem[] = recentPosts.map((post) => {
+ return {
+ cover: post.cover,
+ id: post.slug,
+ meta: [
+ {
+ id: 'publication',
+ term: intl.formatMessage({
+ defaultMessage: 'Published on:',
+ description: 'HomePage: publication date label',
+ id: 'pT5nHk',
+ }),
+ value: [post.dates.publication],
+ },
+ ],
+ title: post.title,
+ url: `/article/${post.slug}`,
+ };
+ });
+
+ return (
+ <CardsList
+ items={posts}
+ titleLevel={3}
+ className={`${styles.list} ${styles['list--cards']}`}
+ />
+ );
+ };
+
+ /**
+ * Create the page sections.
+ *
+ * @param {object} obj - An object containing the section body.
+ * @param {ReactElement[]} obj.children - The section body.
+ * @returns {JSX.Element} A section element.
+ */
+ const getSection = ({
+ children,
+ variant,
+ }: {
+ children: ReactElement[];
+ variant: SectionProps['variant'];
+ }): JSX.Element => {
+ const [headingEl, ...content] = children;
+ const title = headingEl.props.children;
+
+ return (
+ <Section
+ title={title}
+ content={content}
+ variant={variant}
+ className={styles.section}
+ />
+ );
+ };
+
+ const components: NestedMDXComponents = {
+ CodingLinks: CodingLinks,
+ ColdarkRepos: ColdarkRepos,
+ Column: (props: ColumnProps) => <Column {...props} />,
+ Columns: (props: ColumnsProps) => (
+ <Columns className={styles.columns} {...props} />
+ ),
+ Image: (props: ResponsiveImageProps) => <ResponsiveImage {...props} />,
+ LibreLinks: LibreLinks,
+ MoreLinks: MoreLinks,
+ RecentPosts: getRecentPosts,
+ Section: getSection,
+ ShaarliLink: ShaarliLink,
+ };
+
+ const { website } = useSettings();
+
+ const pageTitle = intl.formatMessage(
+ {
+ defaultMessage: '{websiteName} | Front-end developer: WordPress/React',
+ description: 'HomePage: SEO - Page title',
+ id: 'PXp2hv',
+ },
+ { websiteName: website.name }
+ );
+ const pageDescription = intl.formatMessage(
+ {
+ defaultMessage:
+ '{websiteName} is a front-end developer located in France. He codes and he writes mostly about web development and open-source.',
+ description: 'HomePage: SEO - Meta description',
+ id: 'tMuNTy',
+ },
+ { websiteName: website.name }
+ );
+
+ const webpageSchema: WebPage = {
+ '@id': `${website.url}/#home`,
+ '@type': 'WebPage',
+ name: pageTitle,
+ description: pageDescription,
+ author: { '@id': `${website.url}/#branding` },
+ creator: { '@id': `${website.url}/#branding` },
+ editor: { '@id': `${website.url}/#branding` },
+ inLanguage: website.locales.default,
+ license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+ reviewedBy: { '@id': `${website.url}/#branding` },
+ url: `${website.url}`,
+ };
+
+ const schemaJsonLd: Graph = {
+ '@context': 'https://schema.org',
+ '@graph': [webpageSchema],
+ };
+
+ return (
+ <Layout>
+ <Head>
+ <title>{pageTitle}</title>
+ <meta name="description" content={pageDescription} />
+ <meta property="og:url" content={website.url} />
+ <meta property="og:title" content={pageTitle} />
+ <meta property="og:description" content={pageDescription} />
+ </Head>
+ <Script
+ id="schema-homepage"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+ />
+ <HomePageContent components={components} />
+ </Layout>
+ );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+ const translation = await loadTranslation(locale);
+ const recentPosts = await getArticlesCard({ first: 3 });
+
+ return {
+ props: {
+ recentPosts,
+ translation,
+ },
+ };
+};
+
+export default HomePage;
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index e5ce7a5..7aff3e0 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -1,10 +1,19 @@
-import { Article } from '@ts/types/app';
-import { RawArticle, TotalItems } from '@ts/types/raw-data';
+import { type Article, type ArticleCard } from '@ts/types/app';
+import {
+ type RawArticle,
+ type RawArticlePreview,
+ type TotalItems,
+} from '@ts/types/raw-data';
import { getAuthorFromRawData } from '@utils/helpers/author';
+import { getDates } from '@utils/helpers/dates';
import { getImageFromRawData } from '@utils/helpers/images';
import { getPageLinkFromRawData } from '@utils/helpers/pages';
import { EdgesVars, fetchAPI, getAPIUrl, PageInfo } from './api';
-import { articlesQuery, totalArticlesQuery } from './articles.query';
+import {
+ articlesCardQuery,
+ articlesQuery,
+ totalArticlesQuery,
+} from './articles.query';
/**
* Retrieve the total number of articles.
@@ -102,3 +111,40 @@ export const getArticles = async ({
pageInfo: response.posts.pageInfo,
};
};
+
+/**
+ * Convert a raw article preview to an article card.
+ *
+ * @param {RawArticlePreview} data - A raw article preview.
+ * @returns {ArticleCard} An article card.
+ */
+const getArticleCardFromRawData = (data: RawArticlePreview): ArticleCard => {
+ const { databaseId, date, featuredImage, slug, title } = data;
+
+ return {
+ cover: featuredImage ? getImageFromRawData(featuredImage.node) : undefined,
+ dates: getDates(date, ''),
+ id: databaseId,
+ slug,
+ title,
+ };
+};
+
+/**
+ * Retrieve the given number of article cards from API.
+ *
+ * @param {EdgesVars} obj - An object.
+ * @param {number} obj.first - The number of articles.
+ * @returns {Promise<ArticleCard[]>} - The article cards data.
+ */
+export const getArticlesCard = async ({
+ first,
+}: EdgesVars): Promise<ArticleCard[]> => {
+ const response = await fetchAPI<RawArticlePreview, typeof articlesCardQuery>({
+ api: getAPIUrl(),
+ query: articlesCardQuery,
+ variables: { first },
+ });
+
+ return response.posts.nodes.map((node) => getArticleCardFromRawData(node));
+};
diff --git a/src/styles/pages/Home.module.scss b/src/styles/pages/Home.module.scss
deleted file mode 100644
index 8225a57..0000000
--- a/src/styles/pages/Home.module.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-@use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/placeholders";
-
-.links-list {
- @extend %flex-list;
-
- gap: var(--spacing-md);
- margin: 0 0 var(--spacing-md);
-}
-
-.icon--feed {
- width: fun.convert-px(20);
-}
-
-:global {
- [data-theme="dark"] {
- :local {
- .icon--feed {
- filter: brightness(0.8) contrast(1.1);
- }
- }
- }
-}
-
-.section {
- --icon-size: #{fun.convert-px(20)};
-
- composes: grid from "@styles/layout/_grid.scss";
- padding: var(--spacing-md) 0;
- background: var(--color-bg-secondary);
-
- &:not(:last-child) {
- border-bottom: fun.convert-px(1) solid var(--color-border);
- }
-
- &:nth-child(2n) {
- background: var(--color-bg);
- }
-
- > * {
- grid-column: 2;
- }
-
- :global {
- .wp-block-columns {
- margin: 0 0 var(--spacing-md);
- }
- }
-}
diff --git a/src/styles/pages/home.module.scss b/src/styles/pages/home.module.scss
new file mode 100644
index 0000000..873a5a9
--- /dev/null
+++ b/src/styles/pages/home.module.scss
@@ -0,0 +1,36 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.section {
+ --card-width: 25ch;
+
+ &:last-of-type {
+ border-bottom: none;
+ }
+}
+
+.columns {
+ margin: 0 0 var(--spacing-sm);
+}
+
+.list {
+ margin: 0 0 var(--spacing-sm);
+
+ &--cards {
+ @include mix.media("screen") {
+ @include mix.dimensions("md") {
+ margin: 0 calc(var(--spacing-sm) * -1) var(--spacing-sm);
+ }
+ }
+ }
+}
+
+.icon {
+ --icon-size: #{fun.convert-px(20)};
+
+ margin-right: var(--spacing-2xs);
+
+ &--feed {
+ width: var(--icon-size);
+ }
+}
diff --git a/src/utils/hooks/use-settings.tsx b/src/utils/hooks/use-settings.tsx
new file mode 100644
index 0000000..a45e934
--- /dev/null
+++ b/src/utils/hooks/use-settings.tsx
@@ -0,0 +1,112 @@
+import photo from '@assets/images/armand-philippot.jpg';
+import { settings } from '@utils/config';
+import { useRouter } from 'next/router';
+
+export type BlogSettings = {
+ /**
+ * The number of posts per page.
+ */
+ postsPerPage: number;
+};
+
+export type CopyrightSettings = {
+ /**
+ * The copyright end year.
+ */
+ end: string;
+ /**
+ * The copyright start year.
+ */
+ start: string;
+};
+
+export type LocaleSettings = {
+ /**
+ * The default locale.
+ */
+ default: string;
+ /**
+ * The supported locales.
+ */
+ supported: string[];
+};
+
+export type PictureSettings = {
+ /**
+ * The picture height.
+ */
+ height: number;
+ /**
+ * The picture url.
+ */
+ src: string;
+ /**
+ * The picture width.
+ */
+ width: number;
+};
+
+export type WebsiteSettings = {
+ /**
+ * The website name.
+ */
+ name: string;
+ /**
+ * The website baseline.
+ */
+ baseline: string;
+ /**
+ * The website copyright dates.
+ */
+ copyright: CopyrightSettings;
+ /**
+ * The website locales.
+ */
+ locales: LocaleSettings;
+ /**
+ * A picture representing the website.
+ */
+ picture: PictureSettings;
+ /**
+ * The website url.
+ */
+ url: string;
+};
+
+export type UseSettingsReturn = {
+ blog: BlogSettings;
+ website: WebsiteSettings;
+};
+
+/**
+ * Retrieve the website and blog settings.
+ *
+ * @returns {UseSettingsReturn} - An object describing settings.
+ */
+const useSettings = (): UseSettingsReturn => {
+ const { baseline, copyright, locales, name, postsPerPage, url } = settings;
+ const router = useRouter();
+ const locale = router.locale || locales.defaultLocale;
+
+ return {
+ blog: {
+ postsPerPage,
+ },
+ website: {
+ baseline: locale.startsWith('en') ? baseline.en : baseline.fr,
+ copyright: {
+ end: copyright.endYear,
+ start: copyright.startYear,
+ },
+ locales: {
+ default: locales.defaultLocale,
+ supported: locales.supported,
+ },
+ name,
+ picture: photo,
+ url,
+ },
+ };
+};
+
+export default useSettings;