mirror of
https://github.com/theoludwig/theoludwig.git
synced 2024-12-08 00:44:30 +01:00
feat: rewrite to Next.js v13 app directory
Improvements: - Hide switch theme input (ugly little white square) - i18n without subpath (e.g: /fr or /en), same url whatever the locale used
This commit is contained in:
parent
5640f1b434
commit
6b29ce9b15
21
app/error.tsx
Normal file
21
app/error.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
import { ErrorPage } from '@/components/ErrorPage'
|
||||||
|
|
||||||
|
export interface ErrorHandlingProps {
|
||||||
|
error: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorHandling = (props: ErrorHandlingProps): JSX.Element => {
|
||||||
|
const { error } = props
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return <ErrorPage statusCode={500} message='Server error' />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorHandling
|
71
app/layout.tsx
Normal file
71
app/layout.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
import '@fontsource/montserrat/400.css'
|
||||||
|
import '@fontsource/montserrat/600.css'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
import { Providers } from '@/components/Providers'
|
||||||
|
import { Header } from '@/components/Header'
|
||||||
|
import { Footer } from '@/components/Footer'
|
||||||
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
|
const title = 'Théo LUDWIG'
|
||||||
|
const description =
|
||||||
|
'Théo LUDWIG - Developer Full Stack • Open-Source enthusiast'
|
||||||
|
const image = '/images/icon-96x96.png'
|
||||||
|
const url = new URL('https://theoludwig.fr')
|
||||||
|
const locale = 'fr-FR, en-US'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
metadataBase: url,
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
siteName: title,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: image,
|
||||||
|
width: 96,
|
||||||
|
height: 96
|
||||||
|
}
|
||||||
|
],
|
||||||
|
locale,
|
||||||
|
type: 'website'
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: '/images/icon-96x96.png'
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary',
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: [image]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RootLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const RootLayout = (props: RootLayoutProps): JSX.Element => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
const i18n = getI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html suppressHydrationWarning lang={i18n.locale}>
|
||||||
|
<body className='bg-white font-headline text-black dark:bg-black dark:text-white'>
|
||||||
|
<Providers>
|
||||||
|
<Header showLocale />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RootLayout
|
11
app/loading.tsx
Normal file
11
app/loading.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Loader } from '@/components/Loader/Loader'
|
||||||
|
|
||||||
|
const Loading = (): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<main className='flex flex-col flex-1 items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loading
|
12
app/not-found.tsx
Normal file
12
app/not-found.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
import { ErrorPage } from '@/components/ErrorPage'
|
||||||
|
|
||||||
|
const NotFound = (): JSX.Element => {
|
||||||
|
const i18n = getI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorPage statusCode={404} message={i18n.translate('errors.not-found')} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFound
|
59
app/page.tsx
Normal file
59
app/page.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { RevealFade } from '@/components/design/RevealFade'
|
||||||
|
import { Section } from '@/components/design/Section'
|
||||||
|
import { Interests } from '@/components/Interests'
|
||||||
|
import { Portfolio } from '@/components/Portfolio'
|
||||||
|
import { Profile } from '@/components/Profile'
|
||||||
|
import { SocialMediaList } from '@/components/Profile/SocialMediaList'
|
||||||
|
import { Skills } from '@/components/Skills'
|
||||||
|
import { OpenSource } from '@/components/OpenSource'
|
||||||
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
|
const HomePage = (): JSX.Element => {
|
||||||
|
const i18n = getI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
|
||||||
|
<Section isMain id='about'>
|
||||||
|
<Profile />
|
||||||
|
<SocialMediaList />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<RevealFade>
|
||||||
|
<Section
|
||||||
|
id='interests'
|
||||||
|
heading={i18n.translate('home.interests.title')}
|
||||||
|
>
|
||||||
|
<Interests />
|
||||||
|
</Section>
|
||||||
|
</RevealFade>
|
||||||
|
|
||||||
|
<RevealFade>
|
||||||
|
<Section
|
||||||
|
id='skills'
|
||||||
|
heading={i18n.translate('home.skills.title')}
|
||||||
|
withoutShadowContainer
|
||||||
|
>
|
||||||
|
<Skills />
|
||||||
|
</Section>
|
||||||
|
</RevealFade>
|
||||||
|
|
||||||
|
<RevealFade>
|
||||||
|
<Section
|
||||||
|
id='portfolio'
|
||||||
|
heading={i18n.translate('home.portfolio.title')}
|
||||||
|
withoutShadowContainer
|
||||||
|
>
|
||||||
|
<Portfolio />
|
||||||
|
</Section>
|
||||||
|
</RevealFade>
|
||||||
|
|
||||||
|
<RevealFade>
|
||||||
|
<Section id='open-source' heading='Open source' withoutShadowContainer>
|
||||||
|
<OpenSource />
|
||||||
|
</Section>
|
||||||
|
</RevealFade>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomePage
|
@ -1,26 +1,20 @@
|
|||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
import type { FooterProps } from './Footer'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
import { Footer } from './Footer'
|
|
||||||
import { Header } from './Header'
|
|
||||||
|
|
||||||
export interface ErrorPageProps extends FooterProps {
|
export interface ErrorPageProps {
|
||||||
statusCode: number
|
statusCode: number
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorPage: React.FC<ErrorPageProps> = (props) => {
|
export const ErrorPage: React.FC<ErrorPageProps> = (props) => {
|
||||||
const { message, statusCode, version } = props
|
const { message, statusCode } = props
|
||||||
const { t } = useTranslation()
|
const i18n = getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<main className='flex flex-col flex-1 items-center justify-center'>
|
||||||
<div className='flex h-screen flex-col pt-0'>
|
|
||||||
<Header showLanguage />
|
|
||||||
<main className='flex min-w-full flex-1 flex-col items-center justify-center'>
|
|
||||||
<h1 className='my-6 text-4xl font-semibold'>
|
<h1 className='my-6 text-4xl font-semibold'>
|
||||||
{t('errors:error')}{' '}
|
{i18n.translate('errors.error')}{' '}
|
||||||
<span
|
<span
|
||||||
className='text-yellow dark:text-yellow-dark'
|
className='text-yellow dark:text-yellow-dark'
|
||||||
data-cy='status-code'
|
data-cy='status-code'
|
||||||
@ -34,12 +28,9 @@ export const ErrorPage: React.FC<ErrorPageProps> = (props) => {
|
|||||||
href='/'
|
href='/'
|
||||||
className='text-yellow hover:underline dark:text-yellow-dark'
|
className='text-yellow hover:underline dark:text-yellow-dark'
|
||||||
>
|
>
|
||||||
{t('errors:return-to-home-page')}
|
{i18n.translate('errors.return-to-home-page')}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
<Footer version={version} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
|
|
||||||
export interface FooterProps {
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Footer: React.FC<FooterProps> = (props) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { version } = props
|
|
||||||
|
|
||||||
const versionLink = useMemo(() => {
|
|
||||||
return `https://github.com/theoludwig/theoludwig/releases/tag/v${version}`
|
|
||||||
}, [version])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className='flex flex-col items-center justify-center border-t-2 border-gray-600 bg-white py-6 text-lg dark:border-gray-400 dark:bg-black'>
|
|
||||||
<p>
|
|
||||||
<Link
|
|
||||||
href='/'
|
|
||||||
className='text-yellow hover:underline dark:text-yellow-dark'
|
|
||||||
>
|
|
||||||
Théo LUDWIG
|
|
||||||
</Link>{' '}
|
|
||||||
| {t('common:all-rights-reserved')}
|
|
||||||
</p>
|
|
||||||
<p className='mt-1'>
|
|
||||||
Version{' '}
|
|
||||||
<a
|
|
||||||
data-cy='version-link'
|
|
||||||
className='text-yellow hover:underline dark:text-yellow-dark'
|
|
||||||
href={versionLink}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
>
|
|
||||||
{version}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
19
components/Footer/FooterText.tsx
Normal file
19
components/Footer/FooterText.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
|
export const FooterText = (): JSX.Element => {
|
||||||
|
const i18n = getI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
href='/'
|
||||||
|
className='text-yellow hover:underline dark:text-yellow-dark'
|
||||||
|
>
|
||||||
|
Théo LUDWIG
|
||||||
|
</Link>{' '}
|
||||||
|
| {i18n.translate('common.all-rights-reserved')}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
28
components/Footer/FooterVersion.tsx
Normal file
28
components/Footer/FooterVersion.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
interface FooterVersionProps {
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FooterVersion = (props: FooterVersionProps): JSX.Element => {
|
||||||
|
const { version } = props
|
||||||
|
|
||||||
|
const versionLink = useMemo(() => {
|
||||||
|
return `https://github.com/theoludwig/theoludwig/releases/tag/v${version}`
|
||||||
|
}, [version])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className='mt-1'>
|
||||||
|
Version{' '}
|
||||||
|
<a
|
||||||
|
data-cy='version-link'
|
||||||
|
className='text-yellow hover:underline dark:text-yellow-dark'
|
||||||
|
href={versionLink}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
{version}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
14
components/Footer/index.tsx
Normal file
14
components/Footer/index.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { FooterText } from './FooterText'
|
||||||
|
import { FooterVersion } from './FooterVersion'
|
||||||
|
|
||||||
|
export const Footer = async (): Promise<JSX.Element> => {
|
||||||
|
const { readPackage } = await import('read-pkg')
|
||||||
|
const { version } = await readPackage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className='flex flex-col items-center justify-center border-t-2 border-gray-600 bg-white py-6 text-lg dark:border-gray-400 dark:bg-black'>
|
||||||
|
<FooterText />
|
||||||
|
<FooterVersion version={version} />
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
@ -1,45 +0,0 @@
|
|||||||
import NextHead from 'next/head'
|
|
||||||
|
|
||||||
interface HeadProps {
|
|
||||||
title?: string
|
|
||||||
image?: string
|
|
||||||
description?: string
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Head: React.FC<HeadProps> = (props) => {
|
|
||||||
const {
|
|
||||||
title = 'Théo LUDWIG',
|
|
||||||
image = 'https://theoludwig.fr/images/icon-96x96.png',
|
|
||||||
description = 'Théo LUDWIG - Developer Full Stack • Passionate about High-Tech',
|
|
||||||
url = 'https://theoludwig.fr/'
|
|
||||||
} = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NextHead>
|
|
||||||
<title>{title}</title>
|
|
||||||
<link rel='icon' type='image/png' href={image} />
|
|
||||||
|
|
||||||
{/* Meta Tag */}
|
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
|
||||||
<meta name='description' content={description} />
|
|
||||||
<meta name='Language' content='fr-FR, en-US' />
|
|
||||||
<meta name='theme-color' content='#ffd800' />
|
|
||||||
|
|
||||||
{/* Open Graph Metadata */}
|
|
||||||
<meta property='og:title' content={title} />
|
|
||||||
<meta property='og:type' content='website' />
|
|
||||||
<meta property='og:url' content={url} />
|
|
||||||
<meta property='og:image' content={image} />
|
|
||||||
<meta property='og:description' content={description} />
|
|
||||||
<meta property='og:locale' content='fr-FR, en-US' />
|
|
||||||
<meta property='og:site_name' content={title} />
|
|
||||||
|
|
||||||
{/* Twitter card Metadata */}
|
|
||||||
<meta name='twitter:card' content='summary' />
|
|
||||||
<meta name='twitter:description' content={description} />
|
|
||||||
<meta name='twitter:title' content={title} />
|
|
||||||
<meta name='twitter:image' content={image} />
|
|
||||||
</NextHead>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import Image from 'next/image'
|
|
||||||
|
|
||||||
export interface LanguageFlagProps {
|
|
||||||
language: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LanguageFlag: React.FC<LanguageFlagProps> = (props) => {
|
|
||||||
const { language } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Image
|
|
||||||
quality={100}
|
|
||||||
width={35}
|
|
||||||
height={35}
|
|
||||||
src={`/images/languages/${language}.svg`}
|
|
||||||
alt={language}
|
|
||||||
/>
|
|
||||||
<p data-cy='language-flag-text' className='mx-2 text-base'>
|
|
||||||
{language.toUpperCase()}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
30
components/Header/Locales/LocaleFlag.tsx
Normal file
30
components/Header/Locales/LocaleFlag.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
|
import type { CookiesStore } from '@/i18n/i18n.client'
|
||||||
|
import { useI18n } from '@/i18n/i18n.client'
|
||||||
|
|
||||||
|
export interface LocaleFlagProps {
|
||||||
|
locale: string
|
||||||
|
cookiesStore: CookiesStore
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocaleFlag: React.FC<LocaleFlagProps> = (props) => {
|
||||||
|
const { locale, cookiesStore } = props
|
||||||
|
|
||||||
|
const i18n = useI18n(cookiesStore)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
quality={100}
|
||||||
|
width={35}
|
||||||
|
height={35}
|
||||||
|
src={`/images/locales/${locale}.svg`}
|
||||||
|
alt={locale}
|
||||||
|
/>
|
||||||
|
<p data-cy='locale-flag-text' className='mx-2 text-base'>
|
||||||
|
{i18n.translate(`common.${locale}`)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -1,17 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
import setLanguage from 'next-translate/setLanguage'
|
|
||||||
import classNames from 'clsx'
|
import classNames from 'clsx'
|
||||||
|
|
||||||
import i18n from 'i18n.json'
|
import { AVAILABLE_LOCALES } from '@/utils/constants'
|
||||||
|
import type { CookiesStore } from '@/i18n/i18n.client'
|
||||||
|
|
||||||
import { Arrow } from './Arrow'
|
import { Arrow } from './Arrow'
|
||||||
import { LanguageFlag } from './LanguageFlag'
|
import { LocaleFlag } from './LocaleFlag'
|
||||||
|
|
||||||
|
export interface LocalesProps {
|
||||||
|
currentLocale: string
|
||||||
|
cookiesStore: CookiesStore
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Locales = (props: LocalesProps): JSX.Element => {
|
||||||
|
const { currentLocale, cookiesStore } = props
|
||||||
|
|
||||||
export const Language: React.FC = () => {
|
|
||||||
const { lang: currentLanguage } = useTranslation()
|
|
||||||
const [hiddenMenu, setHiddenMenu] = useState(true)
|
const [hiddenMenu, setHiddenMenu] = useState(true)
|
||||||
const languageClickRef = useRef<HTMLDivElement | null>(null)
|
const languageClickRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@ -38,42 +43,48 @@ export const Language: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLanguage = async (language: string): Promise<void> => {
|
const handleLocale = async (locale: string): Promise<void> => {
|
||||||
await setLanguage(language, false)
|
const { setLocale } = await import('@/i18n/i18n.server')
|
||||||
|
setLocale(locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex cursor-pointer flex-col items-center justify-center'>
|
<div className='flex cursor-pointer flex-col items-center justify-center'>
|
||||||
<div
|
<div
|
||||||
ref={languageClickRef}
|
ref={languageClickRef}
|
||||||
data-cy='language-click'
|
data-cy='locale-click'
|
||||||
className='mr-5 flex items-center'
|
className='mr-5 flex items-center'
|
||||||
onClick={handleHiddenMenu}
|
onClick={handleHiddenMenu}
|
||||||
>
|
>
|
||||||
<LanguageFlag language={currentLanguage} />
|
<LocaleFlag
|
||||||
|
locale={currentLocale}
|
||||||
|
cookiesStore={cookiesStore?.toString()}
|
||||||
|
/>
|
||||||
<Arrow />
|
<Arrow />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul
|
<ul
|
||||||
data-cy='languages-list'
|
data-cy='locales-list'
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'absolute top-14 z-10 mr-4 mt-3 flex w-24 list-none flex-col items-center justify-center rounded-lg bg-white p-0 shadow-lightFlag dark:bg-black dark:shadow-darkFlag',
|
'absolute top-14 z-10 mr-4 mt-3 flex w-32 list-none flex-col items-center justify-center rounded-lg bg-white p-0 shadow-lightFlag dark:bg-black dark:shadow-darkFlag',
|
||||||
{ hidden: hiddenMenu }
|
{ hidden: hiddenMenu }
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{i18n.locales.map((language, index) => {
|
{AVAILABLE_LOCALES.filter((locale) => {
|
||||||
if (language === currentLanguage) {
|
return locale !== currentLocale
|
||||||
return <></>
|
}).map((locale) => {
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={index}
|
key={locale}
|
||||||
className='flex h-12 w-full items-center justify-center pl-2 hover:bg-[#4f545c] hover:bg-opacity-20'
|
className='flex h-12 w-full items-center justify-center hover:bg-[#4f545c] hover:bg-opacity-20'
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
return await handleLanguage(language)
|
return await handleLocale(locale)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LanguageFlag language={language} />
|
<LocaleFlag
|
||||||
|
locale={locale}
|
||||||
|
cookiesStore={cookiesStore?.toString()}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
@ -71,7 +71,7 @@ export const SwitchTheme: React.FC = () => {
|
|||||||
data-cy='switch-theme-input'
|
data-cy='switch-theme-input'
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
aria-label='Dark mode toggle'
|
aria-label='Dark mode toggle'
|
||||||
className='absolute m-[-1px] h-[1px] w-[1px] overflow-hidden border-0 p-0'
|
className='absolute m-[-1px] h-[1px] w-[1px] overflow-hidden border-0 p-0 hidden'
|
||||||
defaultChecked
|
defaultChecked
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,15 +1,21 @@
|
|||||||
|
import { cookies } from 'next/headers'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
|
||||||
import { Language } from './Language'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
|
import { Locales } from './Locales'
|
||||||
import { SwitchTheme } from './SwitchTheme'
|
import { SwitchTheme } from './SwitchTheme'
|
||||||
|
|
||||||
export interface HeaderProps {
|
export interface HeaderProps {
|
||||||
showLanguage?: boolean
|
showLocale?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = (props) => {
|
export const Header: React.FC<HeaderProps> = (props) => {
|
||||||
const { showLanguage = false } = props
|
const { showLocale = false } = props
|
||||||
|
|
||||||
|
const cookiesStore = cookies()
|
||||||
|
const i18n = getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className='sticky top-0 z-50 flex w-full justify-between border-b-2 border-gray-600 bg-white px-6 py-2 dark:border-gray-400 dark:bg-black'>
|
<header className='sticky top-0 z-50 flex w-full justify-between border-b-2 border-gray-600 bg-white px-6 py-2 dark:border-gray-400 dark:bg-black'>
|
||||||
@ -38,7 +44,12 @@ export const Header: React.FC<HeaderProps> = (props) => {
|
|||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{showLanguage ? <Language /> : null}
|
{showLocale ? (
|
||||||
|
<Locales
|
||||||
|
currentLocale={i18n.locale}
|
||||||
|
cookiesStore={cookiesStore.toString()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<SwitchTheme />
|
<SwitchTheme />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -1,19 +1,18 @@
|
|||||||
import useTranslation from 'next-translate/useTranslation'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
import type { InterestParagraphProps } from './InterestParagraph'
|
import type { InterestParagraphProps } from './InterestParagraph'
|
||||||
import { InterestParagraph } from './InterestParagraph'
|
import { InterestParagraph } from './InterestParagraph'
|
||||||
import { InterestsList } from './InterestsList'
|
import { InterestsList } from './InterestsList'
|
||||||
|
|
||||||
export const Interests: React.FC = () => {
|
export const Interests: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const i18n = getI18n()
|
||||||
|
|
||||||
const paragraphs: InterestParagraphProps[] = t(
|
let paragraphs = i18n.translate<InterestParagraphProps[]>(
|
||||||
'home:interests.paragraphs',
|
'home.interests.paragraphs'
|
||||||
{},
|
|
||||||
{
|
|
||||||
returnObjects: true
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
if (!Array.isArray(paragraphs)) {
|
||||||
|
paragraphs = []
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='max-w-full'>
|
<div className='max-w-full'>
|
||||||
|
39
components/Loader/Loader.module.css
Normal file
39
components/Loader/Loader.module.css
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
@keyframes progressSpinnerRotate {
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes progressSpinnerDash {
|
||||||
|
0% {
|
||||||
|
stroke-dasharray: 1, 200;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
stroke-dasharray: 89, 200;
|
||||||
|
stroke-dashoffset: -35px;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dasharray: 89, 200;
|
||||||
|
stroke-dashoffset: -124px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressSpinnerSvg {
|
||||||
|
animation: progressSpinnerRotate 2s linear infinite;
|
||||||
|
height: 100%;
|
||||||
|
transform-origin: center center;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
.progressSpinnerCircle {
|
||||||
|
stroke-dasharray: 89, 200;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke: #ffd800;
|
||||||
|
animation: progressSpinnerDash 1.5s ease-in-out infinite;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
33
components/Loader/Loader.tsx
Normal file
33
components/Loader/Loader.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import styles from './Loader.module.css'
|
||||||
|
|
||||||
|
export interface LoaderProps {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Loader: React.FC<LoaderProps> = (props) => {
|
||||||
|
const { width = 50, height = 50, className } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div
|
||||||
|
data-cy='progress-spinner'
|
||||||
|
className='relative mx-auto my-0 before:block before:pt-[100%] before:content-none'
|
||||||
|
style={{ width: `${width}px`, height: `${height}px` }}
|
||||||
|
>
|
||||||
|
<svg className={styles['progressSpinnerSvg']} viewBox='25 25 50 50'>
|
||||||
|
<circle
|
||||||
|
className={styles['progressSpinnerCircle']}
|
||||||
|
cx='50'
|
||||||
|
cy='50'
|
||||||
|
r='20'
|
||||||
|
fill='none'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeMiterlimit='10'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,13 +1,15 @@
|
|||||||
import useTranslation from 'next-translate/useTranslation'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
import { Repository } from './Repository'
|
import { Repository } from './Repository'
|
||||||
|
|
||||||
export const OpenSource: React.FC = () => {
|
export const OpenSource: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const i18n = getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-0 flex max-w-full flex-col items-center'>
|
<div className='mt-0 flex max-w-full flex-col items-center'>
|
||||||
<p className='text-center'>{t('home:open-source.description')}</p>
|
<p className='text-center'>
|
||||||
|
{i18n.translate('home.open-source.description')}
|
||||||
|
</p>
|
||||||
<div className='my-6 grid grid-cols-1 gap-6 md:w-10/12 md:grid-cols-2'>
|
<div className='my-6 grid grid-cols-1 gap-6 md:w-10/12 md:grid-cols-2'>
|
||||||
<Repository
|
<Repository
|
||||||
name='nodejs/node'
|
name='nodejs/node'
|
||||||
|
@ -1,18 +1,15 @@
|
|||||||
import useTranslation from 'next-translate/useTranslation'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
import type { PortfolioItemProps } from './PortfolioItem'
|
import type { PortfolioItemProps } from './PortfolioItem'
|
||||||
import { PortfolioItem } from './PortfolioItem'
|
import { PortfolioItem } from './PortfolioItem'
|
||||||
|
|
||||||
export const Portfolio: React.FC = () => {
|
export const Portfolio: React.FC = () => {
|
||||||
const { t } = useTranslation('home')
|
const i18n = getI18n()
|
||||||
|
|
||||||
const items: PortfolioItemProps[] = t(
|
let items = i18n.translate<PortfolioItemProps[]>('home.portfolio.items')
|
||||||
'home:portfolio.items',
|
if (!Array.isArray(items)) {
|
||||||
{},
|
items = []
|
||||||
{
|
|
||||||
returnObjects: true
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full flex-wrap justify-center px-3'>
|
<div className='flex w-full flex-wrap justify-center px-3'>
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import useTranslation from 'next-translate/useTranslation'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
export const ProfileDescriptionBottom: React.FC = () => {
|
export const ProfileDescriptionBottom: React.FC = () => {
|
||||||
const { t, lang } = useTranslation()
|
const i18n = getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className='mb-8 mt-8 text-base font-normal text-gray dark:text-gray-dark'>
|
<p className='mb-8 mt-8 text-base font-normal text-gray dark:text-gray-dark'>
|
||||||
{t('home:about.description-bottom')}
|
{i18n.translate('home.about.description-bottom')}
|
||||||
{lang === 'fr' ? (
|
{i18n.locale === 'fr-FR' ? (
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import useTranslation from 'next-translate/useTranslation'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
export const ProfileInformation: React.FC = () => {
|
export const ProfileInformation: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const i18n = getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mb-6 border-b-2 border-gray-600 pb-2 font-headline dark:border-gray-400'>
|
<div className='mb-6 border-b-2 border-gray-600 pb-2 font-headline dark:border-gray-400'>
|
||||||
<h1 className='mb-2 text-4xl font-semibold text-yellow dark:text-yellow-dark'>
|
<h1 className='mb-2 text-4xl font-semibold text-yellow dark:text-yellow-dark'>
|
||||||
Théo LUDWIG
|
Théo LUDWIG
|
||||||
</h1>
|
</h1>
|
||||||
<h2 className='mb-3 text-base'>{t('home:about.description')}</h2>
|
<h2 className='mb-3 text-base'>
|
||||||
|
{i18n.translate('home.about.description')}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,21 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
import type { CookiesStore } from '@/i18n/i18n.client'
|
||||||
|
import { useI18n } from '@/i18n/i18n.client'
|
||||||
import { BIRTH_DATE, BIRTH_DATE_STRING, getAge } from '@/utils/getAge'
|
import { BIRTH_DATE, BIRTH_DATE_STRING, getAge } from '@/utils/getAge'
|
||||||
|
|
||||||
import { ProfileItem } from './ProfileItem'
|
import { ProfileItem } from './ProfileItem'
|
||||||
|
|
||||||
export const ProfileList: React.FC = () => {
|
export interface ProfileListProps {
|
||||||
const { t } = useTranslation('home')
|
cookiesStore: CookiesStore
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileList = (props: ProfileListProps): JSX.Element => {
|
||||||
|
const { cookiesStore } = props
|
||||||
|
|
||||||
|
const i18n = useI18n(cookiesStore)
|
||||||
|
|
||||||
const age = useMemo(() => {
|
const age = useMemo(() => {
|
||||||
return getAge(BIRTH_DATE)
|
return getAge(BIRTH_DATE)
|
||||||
@ -17,14 +24,19 @@ export const ProfileList: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ul className='m-0 list-none p-0'>
|
<ul className='m-0 list-none p-0'>
|
||||||
<ProfileItem
|
<ProfileItem
|
||||||
title={t('home:about.pronouns')}
|
title={i18n.translate('home.about.pronouns')}
|
||||||
value={t('home:about.pronouns-value')}
|
value={i18n.translate('home.about.pronouns-value')}
|
||||||
/>
|
/>
|
||||||
<ProfileItem
|
<ProfileItem
|
||||||
title={t('home:about.birth-date')}
|
title={i18n.translate('home.about.birth-date')}
|
||||||
value={`${BIRTH_DATE_STRING} (${age} ${t('home:about.years-old')})`}
|
value={`${BIRTH_DATE_STRING} (${age} ${i18n.translate(
|
||||||
|
'home.about.years-old'
|
||||||
|
)})`}
|
||||||
|
/>
|
||||||
|
<ProfileItem
|
||||||
|
title={i18n.translate('home.about.nationality')}
|
||||||
|
value='Alsace, France'
|
||||||
/>
|
/>
|
||||||
<ProfileItem title={t('home:about.nationality')} value='Alsace, France' />
|
|
||||||
<ProfileItem
|
<ProfileItem
|
||||||
title='Email'
|
title='Email'
|
||||||
value='contact@theoludwig.fr'
|
value='contact@theoludwig.fr'
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
import { ProfileDescriptionBottom } from './ProfileDescriptionBottom'
|
import { ProfileDescriptionBottom } from './ProfileDescriptionBottom'
|
||||||
import { ProfileInformation } from './ProfileInfo'
|
import { ProfileInformation } from './ProfileInfo'
|
||||||
import { ProfileList } from './ProfileList'
|
import { ProfileList } from './ProfileList'
|
||||||
import { ProfileLogo } from './ProfileLogo'
|
import { ProfileLogo } from './ProfileLogo'
|
||||||
|
|
||||||
export const Profile: React.FC = () => {
|
export const Profile: React.FC = () => {
|
||||||
|
const cookiesStore = cookies()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center justify-center px-10 pt-2 md:flex-row md:pt-10'>
|
<div className='flex flex-col items-center justify-center px-10 pt-2 md:flex-row md:pt-10'>
|
||||||
<ProfileLogo />
|
<ProfileLogo />
|
||||||
<div>
|
<div>
|
||||||
<ProfileInformation />
|
<ProfileInformation />
|
||||||
<ProfileList />
|
<ProfileList cookiesStore={cookiesStore.toString()} />
|
||||||
<ProfileDescriptionBottom />
|
<ProfileDescriptionBottom />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
15
components/Providers.tsx
Normal file
15
components/Providers.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
|
||||||
|
type ProvidersProps = React.PropsWithChildren
|
||||||
|
|
||||||
|
export const Providers = (props: ProvidersProps): JSX.Element => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider attribute='class' defaultTheme='dark'>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
@ -1,6 +1,8 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import type { SkillName } from './skills'
|
import type { SkillName } from './skills'
|
||||||
import { skills } from './skills'
|
import { skills } from './skills'
|
||||||
@ -11,18 +13,31 @@ export interface SkillComponentProps {
|
|||||||
|
|
||||||
export const SkillComponent: React.FC<SkillComponentProps> = (props) => {
|
export const SkillComponent: React.FC<SkillComponentProps> = (props) => {
|
||||||
const { skill } = props
|
const { skill } = props
|
||||||
|
|
||||||
const skillProperties = skills[skill]
|
const skillProperties = skills[skill]
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const image = useMemo(() => {
|
const image = useMemo(() => {
|
||||||
if (typeof skillProperties.image === 'string') {
|
if (typeof skillProperties.image === 'string') {
|
||||||
return skillProperties.image
|
return skillProperties.image
|
||||||
}
|
}
|
||||||
|
if (!mounted) {
|
||||||
|
return skillProperties.image.dark
|
||||||
|
}
|
||||||
if (theme === 'light') {
|
if (theme === 'light') {
|
||||||
return skillProperties.image.light
|
return skillProperties.image.light
|
||||||
}
|
}
|
||||||
return skillProperties.image.dark
|
return skillProperties.image.dark
|
||||||
}, [skillProperties, theme])
|
}, [skillProperties, theme, mounted])
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import useTranslation from 'next-translate/useTranslation'
|
import { getI18n } from '@/i18n/i18n.server'
|
||||||
|
|
||||||
import { SkillComponent } from './Skill'
|
import { SkillComponent } from './Skill'
|
||||||
import { SkillsSection } from './SkillsSection'
|
import { SkillsSection } from './SkillsSection'
|
||||||
|
|
||||||
export const Skills: React.FC = () => {
|
export const Skills: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const i18n = getI18n()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SkillsSection title={t('home:skills.languages')}>
|
<SkillsSection title={i18n.translate('home.skills.languages')}>
|
||||||
<SkillComponent skill='TypeScript' />
|
<SkillComponent skill='TypeScript' />
|
||||||
<SkillComponent skill='Python' />
|
<SkillComponent skill='Python' />
|
||||||
<SkillComponent skill='C/C++' />
|
<SkillComponent skill='C/C++' />
|
||||||
@ -29,7 +29,7 @@ export const Skills: React.FC = () => {
|
|||||||
<SkillComponent skill='PostgreSQL' />
|
<SkillComponent skill='PostgreSQL' />
|
||||||
</SkillsSection>
|
</SkillsSection>
|
||||||
|
|
||||||
<SkillsSection title={t('home:skills.software-tools')}>
|
<SkillsSection title={i18n.translate('home.skills.software-tools')}>
|
||||||
<SkillComponent skill='GNU/Linux' />
|
<SkillComponent skill='GNU/Linux' />
|
||||||
<SkillComponent skill='Arch Linux' />
|
<SkillComponent skill='Arch Linux' />
|
||||||
<SkillComponent skill='Visual Studio Code' />
|
<SkillComponent skill='Visual Studio Code' />
|
||||||
|
@ -6,7 +6,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
|
|||||||
import date from 'date-and-time'
|
import date from 'date-and-time'
|
||||||
|
|
||||||
const jsonCurriculumVitaeURL = new URL(
|
const jsonCurriculumVitaeURL = new URL(
|
||||||
'../curriculum-vitae.jsonc',
|
'./curriculum-vitae.jsonc',
|
||||||
import.meta.url
|
import.meta.url
|
||||||
)
|
)
|
||||||
const dataCurriculumVitaeStringJSON = await fs.promises.readFile(
|
const dataCurriculumVitaeStringJSON = await fs.promises.readFile(
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
import { Footer } from '@/components/Footer'
|
|
||||||
|
|
||||||
describe('<Footer />', () => {
|
|
||||||
it('should render with appropriate link tag version', () => {
|
|
||||||
const version = '1.0.0'
|
|
||||||
cy.mount(<Footer version={version} />)
|
|
||||||
cy.contains('Théo LUDWIG')
|
|
||||||
.get('[data-cy=version-link]')
|
|
||||||
.should('have.text', version)
|
|
||||||
.should(
|
|
||||||
'have.attr',
|
|
||||||
'href',
|
|
||||||
`https://github.com/theoludwig/theoludwig/releases/tag/v${version}`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
@ -37,24 +37,24 @@ describe('Common > Header', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Switch Language', () => {
|
describe('Switch Language', () => {
|
||||||
it('should switch language from EN (default) to FR', () => {
|
it('should switch locale from EN (default) to FR', () => {
|
||||||
cy.get('h1').contains('Théo LUDWIG')
|
cy.get('h1').contains('Théo LUDWIG')
|
||||||
cy.get('[data-cy=language-flag-text]').contains('EN')
|
cy.get('[data-cy=locale-flag-text]').contains('EN')
|
||||||
cy.get('[data-cy=languages-list]').should('not.be.visible')
|
cy.get('[data-cy=locales-list]').should('not.be.visible')
|
||||||
cy.get('[data-cy=language-click]').click()
|
cy.get('[data-cy=locale-click]').click()
|
||||||
cy.get('[data-cy=languages-list]').should('be.visible')
|
cy.get('[data-cy=locales-list]').should('be.visible')
|
||||||
cy.get('[data-cy=languages-list] > li:first-child').contains('FR').click()
|
cy.get('[data-cy=locales-list] > li:first-child').contains('FR').click()
|
||||||
cy.get('[data-cy=languages-list]').should('not.be.visible')
|
cy.get('[data-cy=locales-list]').should('not.be.visible')
|
||||||
cy.get('[data-cy=language-flag-text]').contains('FR')
|
cy.get('[data-cy=locale-flag-text]').contains('FR')
|
||||||
cy.get('h1').contains('Théo LUDWIG')
|
cy.get('h1').contains('Théo LUDWIG')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close the language list menu when clicking outside', () => {
|
it('should close the locale list menu when clicking outside', () => {
|
||||||
cy.get('[data-cy=languages-list]').should('not.be.visible')
|
cy.get('[data-cy=locales-list]').should('not.be.visible')
|
||||||
cy.get('[data-cy=language-click]').click()
|
cy.get('[data-cy=locale-click]').click()
|
||||||
cy.get('[data-cy=languages-list]').should('be.visible')
|
cy.get('[data-cy=locales-list]').should('be.visible')
|
||||||
cy.get('h1').click()
|
cy.get('h1').click()
|
||||||
cy.get('[data-cy=languages-list]').should('not.be.visible')
|
cy.get('[data-cy=locales-list]').should('not.be.visible')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
describe('Page /blog/[slug]', () => {
|
describe('Page /blog/[slug]', () => {
|
||||||
it('should displays the first blog post (`hello-world`)', () => {
|
it('should displays the first blog post (`hello-world`)', () => {
|
||||||
cy.visit('/blog/hello-world')
|
cy.visit('/blog/hello-world')
|
||||||
cy.get('[data-cy=language-flag-text]').should('not.exist')
|
cy.get('[data-cy=locale-flag-text]').should('not.exist')
|
||||||
cy.get('h1').should('have.text', '👋 Hello, world!')
|
cy.get('h1').should('have.text', '👋 Hello, world!')
|
||||||
cy.get('.prose a:visible').should('have.attr', 'target', '_blank')
|
cy.get('.prose a:visible').should('have.attr', 'target', '_blank')
|
||||||
})
|
})
|
||||||
|
10
i18n.json
10
i18n.json
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"locales": ["en", "fr"],
|
|
||||||
"defaultLocale": "en",
|
|
||||||
"pages": {
|
|
||||||
"*": ["common"],
|
|
||||||
"/": ["home"],
|
|
||||||
"/404": ["errors"],
|
|
||||||
"/500": ["errors"]
|
|
||||||
}
|
|
||||||
}
|
|
12
i18n/i18n.client.ts
Normal file
12
i18n/i18n.client.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import UniversalCookie from 'universal-cookie'
|
||||||
|
import type { I18n } from 'i18n-js'
|
||||||
|
|
||||||
|
import { i18n } from './i18n'
|
||||||
|
|
||||||
|
export type CookiesStore = string | object | null | undefined
|
||||||
|
|
||||||
|
export const useI18n = (cookiesStore: CookiesStore): I18n => {
|
||||||
|
const universalCookie = new UniversalCookie(cookiesStore)
|
||||||
|
i18n.locale = universalCookie.get('locale') ?? i18n.defaultLocale
|
||||||
|
return i18n
|
||||||
|
}
|
20
i18n/i18n.server.ts
Normal file
20
i18n/i18n.server.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import type { I18n } from 'i18n-js'
|
||||||
|
|
||||||
|
import { COOKIE_MAX_AGE } from '@/utils/constants'
|
||||||
|
|
||||||
|
import { i18n } from './i18n'
|
||||||
|
|
||||||
|
export const setLocale = (locale: string): void => {
|
||||||
|
cookies().set('locale', locale, {
|
||||||
|
path: '/',
|
||||||
|
maxAge: COOKIE_MAX_AGE
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getI18n = (): I18n => {
|
||||||
|
i18n.locale = cookies().get('locale')?.value ?? i18n.defaultLocale
|
||||||
|
return i18n
|
||||||
|
}
|
32
i18n/i18n.ts
Normal file
32
i18n/i18n.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { I18n } from 'i18n-js'
|
||||||
|
|
||||||
|
import { DEFAULT_LOCALE, AVAILABLE_LOCALES } from '@/utils/constants'
|
||||||
|
|
||||||
|
import commonEnglish from './translations/en-US/common.json'
|
||||||
|
import errorsEnglish from './translations/en-US/errors.json'
|
||||||
|
import homeEnglish from './translations/en-US/home.json'
|
||||||
|
import commonFrench from './translations/fr-FR/common.json'
|
||||||
|
import errorsFrench from './translations/fr-FR/errors.json'
|
||||||
|
import homeFrench from './translations/fr-FR/home.json'
|
||||||
|
|
||||||
|
export const i18nOptions = {
|
||||||
|
defaultLocale: DEFAULT_LOCALE,
|
||||||
|
availableLocales: AVAILABLE_LOCALES.slice(),
|
||||||
|
enableFallback: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = new I18n(
|
||||||
|
{
|
||||||
|
'en-US': {
|
||||||
|
common: commonEnglish,
|
||||||
|
errors: errorsEnglish,
|
||||||
|
home: homeEnglish
|
||||||
|
},
|
||||||
|
'fr-FR': {
|
||||||
|
common: commonFrench,
|
||||||
|
errors: errorsFrench,
|
||||||
|
home: homeFrench
|
||||||
|
}
|
||||||
|
},
|
||||||
|
i18nOptions
|
||||||
|
)
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"english": "English",
|
"en-US": "English",
|
||||||
"french": "French",
|
"fr-FR": "French",
|
||||||
"all-rights-reserved": "All rights reserved",
|
"all-rights-reserved": "All rights reserved",
|
||||||
"home": "Home"
|
"home": "Home"
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"english": "Anglais",
|
"en-US": "Anglais",
|
||||||
"french": "Français",
|
"fr-FR": "Français",
|
||||||
"all-rights-reserved": "Tous droits réservés",
|
"all-rights-reserved": "Tous droits réservés",
|
||||||
"home": "Accueil"
|
"home": "Accueil"
|
||||||
}
|
}
|
53
middleware.ts
Normal file
53
middleware.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
import { match } from '@formatjs/intl-localematcher'
|
||||||
|
import Negotiator from 'negotiator'
|
||||||
|
|
||||||
|
import type { AvailableLocale } from '@/utils/constants'
|
||||||
|
import {
|
||||||
|
COOKIE_MAX_AGE,
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
AVAILABLE_LOCALES
|
||||||
|
} from '@/utils/constants'
|
||||||
|
|
||||||
|
export const middleware = (request: NextRequest): NextResponse => {
|
||||||
|
const response = NextResponse.next()
|
||||||
|
|
||||||
|
let locale = request.cookies.get('locale')?.value
|
||||||
|
if (
|
||||||
|
locale == null ||
|
||||||
|
!AVAILABLE_LOCALES.includes(locale as AvailableLocale)
|
||||||
|
) {
|
||||||
|
const headers = {
|
||||||
|
'accept-language':
|
||||||
|
request.headers.get('accept-language') ?? DEFAULT_LOCALE
|
||||||
|
}
|
||||||
|
const languages = new Negotiator({ headers }).languages()
|
||||||
|
locale = match(languages, AVAILABLE_LOCALES.slice(), DEFAULT_LOCALE)
|
||||||
|
response.cookies.set('locale', locale, {
|
||||||
|
path: '/',
|
||||||
|
maxAge: COOKIE_MAX_AGE
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = request.cookies.get('theme')?.value ?? 'dark'
|
||||||
|
response.cookies.set('theme', theme, {
|
||||||
|
path: '/',
|
||||||
|
expires: COOKIE_MAX_AGE
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
'/((?!api|_next/static|_next/image|favicon.ico).*)'
|
||||||
|
]
|
||||||
|
}
|
@ -1,9 +1,10 @@
|
|||||||
const nextTranslate = require('next-translate-plugin')
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone'
|
output: 'standalone',
|
||||||
|
experimental: {
|
||||||
|
serverActions: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextTranslate(nextConfig)
|
module.exports = nextConfig
|
||||||
|
185
package-lock.json
generated
185
package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/montserrat": "5.0.5",
|
"@fontsource/montserrat": "5.0.5",
|
||||||
|
"@formatjs/intl-localematcher": "0.4.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "6.4.0",
|
"@fortawesome/free-brands-svg-icons": "6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
||||||
@ -19,11 +20,12 @@
|
|||||||
"date-and-time": "3.0.2",
|
"date-and-time": "3.0.2",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
|
"i18n-js": "4.3.0",
|
||||||
"katex": "0.16.8",
|
"katex": "0.16.8",
|
||||||
|
"negotiator": "0.6.3",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-mdx-remote": "4.4.1",
|
"next-mdx-remote": "4.4.1",
|
||||||
"next-themes": "0.2.1",
|
"next-themes": "0.2.1",
|
||||||
"next-translate": "2.5.2",
|
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"read-pkg": "8.0.0",
|
"read-pkg": "8.0.0",
|
||||||
@ -45,6 +47,7 @@
|
|||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/typography": "0.5.9",
|
"@tailwindcss/typography": "0.5.9",
|
||||||
"@tsconfig/strictest": "2.0.1",
|
"@tsconfig/strictest": "2.0.1",
|
||||||
|
"@types/negotiator": "0.6.1",
|
||||||
"@types/node": "20.4.5",
|
"@types/node": "20.4.5",
|
||||||
"@types/react": "18.2.17",
|
"@types/react": "18.2.17",
|
||||||
"@types/unist": "3.0.0",
|
"@types/unist": "3.0.0",
|
||||||
@ -54,7 +57,7 @@
|
|||||||
"curriculum-vitae": "file:./curriculum-vitae",
|
"curriculum-vitae": "file:./curriculum-vitae",
|
||||||
"cypress": "12.17.2",
|
"cypress": "12.17.2",
|
||||||
"editorconfig-checker": "5.1.1",
|
"editorconfig-checker": "5.1.1",
|
||||||
"eslint": "8.45.0",
|
"eslint": "8.46.0",
|
||||||
"eslint-config-conventions": "11.0.1",
|
"eslint-config-conventions": "11.0.1",
|
||||||
"eslint-config-next": "13.4.12",
|
"eslint-config-next": "13.4.12",
|
||||||
"eslint-config-prettier": "8.9.0",
|
"eslint-config-prettier": "8.9.0",
|
||||||
@ -67,7 +70,6 @@
|
|||||||
"lint-staged": "13.2.3",
|
"lint-staged": "13.2.3",
|
||||||
"markdownlint-cli2": "0.8.1",
|
"markdownlint-cli2": "0.8.1",
|
||||||
"markdownlint-rule-relative-links": "2.1.0",
|
"markdownlint-rule-relative-links": "2.1.0",
|
||||||
"next-translate-plugin": "2.5.2",
|
|
||||||
"postcss": "8.4.27",
|
"postcss": "8.4.27",
|
||||||
"prettier": "3.0.0",
|
"prettier": "3.0.0",
|
||||||
"prettier-plugin-tailwindcss": "0.4.1",
|
"prettier-plugin-tailwindcss": "0.4.1",
|
||||||
@ -927,9 +929,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc": {
|
"node_modules/@eslint/eslintrc": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.1.tgz",
|
||||||
"integrity": "sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==",
|
"integrity": "sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^6.12.4",
|
"ajv": "^6.12.4",
|
||||||
@ -972,9 +974,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "8.44.0",
|
"version": "8.46.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.46.0.tgz",
|
||||||
"integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==",
|
"integrity": "sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
@ -985,6 +987,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.0.5.tgz",
|
||||||
"integrity": "sha512-kR4hwC44p0GZQsy1oA2X8EdFMg/lC7qwVX9jNgNBQ+uSZXKoV4Vyc1VepcRAcGRKPIgcc/3OC1j7FNQDYpFLJA=="
|
"integrity": "sha512-kR4hwC44p0GZQsy1oA2X8EdFMg/lC7qwVX9jNgNBQ+uSZXKoV4Vyc1VepcRAcGRKPIgcc/3OC1j7FNQDYpFLJA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@formatjs/intl-localematcher": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-bRTd+rKomvfdS4QDlVJ6TA/Jx1F2h/TBVO5LjvhQ7QPPHp19oPNMIum7W2CMEReq/zPxpmCeB31F9+5gl/qtvw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
|
||||||
@ -2460,6 +2470,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz",
|
||||||
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
|
"integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/negotiator": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-c4mvXFByghezQ/eVGN5HvH/jI63vm3B7FiE81BUzDAWmuiohRecCO6ddU60dfq29oKUMiQujsoB2h0JQC7JHKA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.4.5",
|
"version": "20.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz",
|
||||||
@ -3345,6 +3361,14 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bignumber.js": {
|
||||||
|
"version": "9.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz",
|
||||||
|
"integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
|
||||||
@ -3462,9 +3486,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.21.9",
|
"version": "4.21.10",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz",
|
||||||
"integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==",
|
"integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@ -3481,9 +3505,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001503",
|
"caniuse-lite": "^1.0.30001517",
|
||||||
"electron-to-chromium": "^1.4.431",
|
"electron-to-chromium": "^1.4.477",
|
||||||
"node-releases": "^2.0.12",
|
"node-releases": "^2.0.13",
|
||||||
"update-browserslist-db": "^1.0.11"
|
"update-browserslist-db": "^1.0.11"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@ -3646,9 +3670,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001517",
|
"version": "1.0.30001518",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001518.tgz",
|
||||||
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
|
"integrity": "sha512-rup09/e3I0BKjncL+FesTayKtPrdwKhUufQFd3riFw1hHg8JmIFoInYfB102cFcY/pPgGmdyl/iy+jgiDi2vdA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@ -5134,9 +5158,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.4.475",
|
"version": "1.4.477",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.475.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.477.tgz",
|
||||||
"integrity": "sha512-mTye5u5P98kSJO2n7zYALhpJDmoSQejIGya0iR01GpoRady8eK3bw7YHHnjA1Rfi4ZSLdpuzlAC7Zw+1Zu7Z6A==",
|
"integrity": "sha512-shUVy6Eawp33dFBFIoYbIwLHrX0IZ857AlH9ug2o4rvbWmpaCUdBpQ5Zw39HRrfzAFm4APJE9V+E2A/WB0YqJw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
@ -5167,9 +5191,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/enquirer": {
|
"node_modules/enquirer": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
|
||||||
"integrity": "sha512-ehu97t6FTYK2I3ZYtnp0BZ9vt0mvEL/cnHBds7Ct6jo9VX1VIkiFhOvVRWh6eblQqd7KOoICIQV+syZ3neXO/Q==",
|
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-colors": "^4.1.1",
|
"ansi-colors": "^4.1.1",
|
||||||
@ -5473,27 +5497,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "8.45.0",
|
"version": "8.46.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.46.0.tgz",
|
||||||
"integrity": "sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==",
|
"integrity": "sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.4.0",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
"@eslint/eslintrc": "^2.1.0",
|
"@eslint/eslintrc": "^2.1.1",
|
||||||
"@eslint/js": "8.44.0",
|
"@eslint/js": "^8.46.0",
|
||||||
"@humanwhocodes/config-array": "^0.11.10",
|
"@humanwhocodes/config-array": "^0.11.10",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
"@nodelib/fs.walk": "^1.2.8",
|
"@nodelib/fs.walk": "^1.2.8",
|
||||||
"ajv": "^6.10.0",
|
"ajv": "^6.12.4",
|
||||||
"chalk": "^4.0.0",
|
"chalk": "^4.0.0",
|
||||||
"cross-spawn": "^7.0.2",
|
"cross-spawn": "^7.0.2",
|
||||||
"debug": "^4.3.2",
|
"debug": "^4.3.2",
|
||||||
"doctrine": "^3.0.0",
|
"doctrine": "^3.0.0",
|
||||||
"escape-string-regexp": "^4.0.0",
|
"escape-string-regexp": "^4.0.0",
|
||||||
"eslint-scope": "^7.2.0",
|
"eslint-scope": "^7.2.2",
|
||||||
"eslint-visitor-keys": "^3.4.1",
|
"eslint-visitor-keys": "^3.4.2",
|
||||||
"espree": "^9.6.0",
|
"espree": "^9.6.1",
|
||||||
"esquery": "^1.4.2",
|
"esquery": "^1.4.2",
|
||||||
"esutils": "^2.0.2",
|
"esutils": "^2.0.2",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@ -5944,9 +5968,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react": {
|
"node_modules/eslint-plugin-react": {
|
||||||
"version": "7.33.0",
|
"version": "7.33.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.1.tgz",
|
||||||
"integrity": "sha512-qewL/8P34WkY8jAqdQxsiL82pDUeT7nhs8IsuXgfgnsEloKCT4miAV9N9kGtx7/KM9NH/NCGUE7Edt9iGxLXFw==",
|
"integrity": "sha512-L093k0WAMvr6VhNwReB8VgOq5s2LesZmrpPdKz/kZElQDzqS7G7+DnKoqT+w4JwuiGeAhAvHO0fvy0Eyk4ejDA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"array-includes": "^3.1.6",
|
"array-includes": "^3.1.6",
|
||||||
@ -6070,9 +6094,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-scope": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "7.2.1",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||||
"integrity": "sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==",
|
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esrecurse": "^4.3.0",
|
"esrecurse": "^4.3.0",
|
||||||
@ -6086,9 +6110,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-visitor-keys": {
|
"node_modules/eslint-visitor-keys": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz",
|
||||||
"integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==",
|
"integrity": "sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
@ -7791,6 +7815,15 @@
|
|||||||
"url": "https://github.com/sponsors/typicode"
|
"url": "https://github.com/sponsors/typicode"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18n-js": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18n-js/-/i18n-js-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-PX93eT6WPV6Ym6mHtFKGDRZB0zwDX7HUPkgprjsZ28J6/Ohw1nvRYuM93or3pWv2VLxs6XfBf7X9Fc/YAZNEtQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"bignumber.js": "*",
|
||||||
|
"make-plural": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@ -8678,9 +8711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
"integrity": "sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==",
|
"integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"array-includes": "^3.1.6",
|
"array-includes": "^3.1.6",
|
||||||
@ -9405,6 +9438,11 @@
|
|||||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/make-plural": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-/K3BC0KIsO+WK2i94LkMPv3wslMrazrQhfi5We9fMbLlLjzoOSJWr7TAdupLlDWaJcWxwoNosBkhFDejiu5VDw=="
|
||||||
|
},
|
||||||
"node_modules/map-obj": {
|
"node_modules/map-obj": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
|
||||||
@ -10975,6 +11013,14 @@
|
|||||||
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
|
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/neo-async": {
|
"node_modules/neo-async": {
|
||||||
"version": "2.6.2",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
@ -11066,47 +11112,6 @@
|
|||||||
"react-dom": "*"
|
"react-dom": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-translate": {
|
|
||||||
"version": "2.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/next-translate/-/next-translate-2.5.2.tgz",
|
|
||||||
"integrity": "sha512-5FusT+XlQGA1BAJaYu3jP8nPs3BDe/8f1u+MVM5CmIqmwOS62jUG5svq7HB4ORC6N6mvsPd7xGi3qxWyAYtwBQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.10.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"next": ">= 13.2.5",
|
|
||||||
"react": ">= 18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/next-translate-plugin": {
|
|
||||||
"version": "2.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/next-translate-plugin/-/next-translate-plugin-2.5.2.tgz",
|
|
||||||
"integrity": "sha512-I2PDkbUM91CrOYNPVxhWxfQituSN62Y6f5EQfrZwtJToWc5HqnKs9dFjyQ+VGY3EXx6rPYgK03MVNtI+pc1HBA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"dependencies": {
|
|
||||||
"typescript": "4.5.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.15.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"next-translate": ">= 2.4.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/next-translate-plugin/node_modules/typescript": {
|
|
||||||
"version": "4.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz",
|
|
||||||
"integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==",
|
|
||||||
"dev": true,
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.14",
|
"version": "8.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
|
||||||
@ -16367,9 +16372,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "3.26.3",
|
"version": "3.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.26.3.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.27.0.tgz",
|
||||||
"integrity": "sha512-7Tin0C8l86TkpcMtXvQu6saWH93nhG3dGQ1/+l5V2TDMceTxO7kDiK6GzbfLWNNxqJXm591PcEZUozZm51ogwQ==",
|
"integrity": "sha512-aOltLCrYZ0FhJDm7fCqwTjIUEVjWjcydKBV/Zeid6Mn8BWgDCUBBWT5beM5ieForYNo/1ZHuGJdka26kvQ3Gzg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
|
10
package.json
10
package.json
@ -30,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/montserrat": "5.0.5",
|
"@fontsource/montserrat": "5.0.5",
|
||||||
|
"@formatjs/intl-localematcher": "0.4.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "6.4.0",
|
"@fortawesome/free-brands-svg-icons": "6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
||||||
@ -39,11 +40,12 @@
|
|||||||
"date-and-time": "3.0.2",
|
"date-and-time": "3.0.2",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
|
"i18n-js": "4.3.0",
|
||||||
"katex": "0.16.8",
|
"katex": "0.16.8",
|
||||||
|
"negotiator": "0.6.3",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-mdx-remote": "4.4.1",
|
"next-mdx-remote": "4.4.1",
|
||||||
"next-themes": "0.2.1",
|
"next-themes": "0.2.1",
|
||||||
"next-translate": "2.5.2",
|
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"read-pkg": "8.0.0",
|
"read-pkg": "8.0.0",
|
||||||
@ -65,15 +67,17 @@
|
|||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/typography": "0.5.9",
|
"@tailwindcss/typography": "0.5.9",
|
||||||
"@tsconfig/strictest": "2.0.1",
|
"@tsconfig/strictest": "2.0.1",
|
||||||
|
"@types/negotiator": "0.6.1",
|
||||||
"@types/node": "20.4.5",
|
"@types/node": "20.4.5",
|
||||||
"@types/react": "18.2.17",
|
"@types/react": "18.2.17",
|
||||||
"@types/unist": "3.0.0",
|
"@types/unist": "3.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "6.2.0",
|
"@typescript-eslint/eslint-plugin": "6.2.0",
|
||||||
"@typescript-eslint/parser": "6.2.0",
|
"@typescript-eslint/parser": "6.2.0",
|
||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
|
"curriculum-vitae": "file:./curriculum-vitae",
|
||||||
"cypress": "12.17.2",
|
"cypress": "12.17.2",
|
||||||
"editorconfig-checker": "5.1.1",
|
"editorconfig-checker": "5.1.1",
|
||||||
"eslint": "8.45.0",
|
"eslint": "8.46.0",
|
||||||
"eslint-config-conventions": "11.0.1",
|
"eslint-config-conventions": "11.0.1",
|
||||||
"eslint-config-next": "13.4.12",
|
"eslint-config-next": "13.4.12",
|
||||||
"eslint-config-prettier": "8.9.0",
|
"eslint-config-prettier": "8.9.0",
|
||||||
@ -83,11 +87,9 @@
|
|||||||
"eslint-plugin-unicorn": "48.0.1",
|
"eslint-plugin-unicorn": "48.0.1",
|
||||||
"html-w3c-validator": "1.4.0",
|
"html-w3c-validator": "1.4.0",
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"curriculum-vitae": "file:./curriculum-vitae",
|
|
||||||
"lint-staged": "13.2.3",
|
"lint-staged": "13.2.3",
|
||||||
"markdownlint-cli2": "0.8.1",
|
"markdownlint-cli2": "0.8.1",
|
||||||
"markdownlint-rule-relative-links": "2.1.0",
|
"markdownlint-rule-relative-links": "2.1.0",
|
||||||
"next-translate-plugin": "2.5.2",
|
|
||||||
"postcss": "8.4.27",
|
"postcss": "8.4.27",
|
||||||
"prettier": "3.0.0",
|
"prettier": "3.0.0",
|
||||||
"prettier-plugin-tailwindcss": "0.4.1",
|
"prettier-plugin-tailwindcss": "0.4.1",
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
import type { GetStaticProps, NextPage } from 'next'
|
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
|
|
||||||
import { ErrorPage } from 'components/ErrorPage'
|
|
||||||
import { Head } from 'components/Head'
|
|
||||||
import type { FooterProps } from 'components/Footer'
|
|
||||||
|
|
||||||
interface Error404Props extends FooterProps {}
|
|
||||||
|
|
||||||
const Error404: NextPage<Error404Props> = (props) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { version } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title='404 | Théo LUDWIG' />
|
|
||||||
<ErrorPage
|
|
||||||
statusCode={404}
|
|
||||||
message={t('errors:not-found')}
|
|
||||||
version={version}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<Error404Props> = async () => {
|
|
||||||
const { readPackage } = await import('read-pkg')
|
|
||||||
const { version } = await readPackage()
|
|
||||||
return { props: { version } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Error404
|
|
@ -1,32 +0,0 @@
|
|||||||
import type { GetStaticProps, NextPage } from 'next'
|
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
|
|
||||||
import { ErrorPage } from 'components/ErrorPage'
|
|
||||||
import { Head } from 'components/Head'
|
|
||||||
import type { FooterProps } from 'components/Footer'
|
|
||||||
|
|
||||||
interface Error500Props extends FooterProps {}
|
|
||||||
|
|
||||||
const Error500: NextPage<Error500Props> = (props) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { version } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title='500 | Théo LUDWIG' />
|
|
||||||
<ErrorPage
|
|
||||||
statusCode={500}
|
|
||||||
message={t('errors:server-error')}
|
|
||||||
version={version}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<Error500Props> = async () => {
|
|
||||||
const { readPackage } = await import('read-pkg')
|
|
||||||
const { version } = await readPackage()
|
|
||||||
return { props: { version } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Error500
|
|
@ -1,33 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import type { AppType } from 'next/app'
|
|
||||||
import { ThemeProvider } from 'next-themes'
|
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
import UniversalCookie from 'universal-cookie'
|
|
||||||
|
|
||||||
import 'styles/global.css'
|
|
||||||
import '@fontsource/montserrat/400.css'
|
|
||||||
import '@fontsource/montserrat/600.css'
|
|
||||||
|
|
||||||
const universalCookie = new UniversalCookie()
|
|
||||||
|
|
||||||
/** how long in seconds, until the cookie expires (10 years) */
|
|
||||||
const COOKIE_MAX_AGE = 10 * 365.25 * 24 * 60 * 60
|
|
||||||
|
|
||||||
const Application: AppType = ({ Component, pageProps }) => {
|
|
||||||
const { lang } = useTranslation()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
universalCookie.set('NEXT_LOCALE', lang, {
|
|
||||||
path: '/',
|
|
||||||
maxAge: COOKIE_MAX_AGE
|
|
||||||
})
|
|
||||||
}, [lang])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider attribute='class' defaultTheme='dark'>
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Application
|
|
@ -1,15 +0,0 @@
|
|||||||
import { Html, Head, Main, NextScript } from 'next/document'
|
|
||||||
|
|
||||||
const Document: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<Html>
|
|
||||||
<Head />
|
|
||||||
<body className='bg-white font-headline text-black dark:bg-black dark:text-white'>
|
|
||||||
<Main />
|
|
||||||
<NextScript />
|
|
||||||
</body>
|
|
||||||
</Html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Document
|
|
@ -1,152 +0,0 @@
|
|||||||
import type { GetStaticProps, GetStaticPaths, NextPage } from 'next'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import { MDXRemote } from 'next-mdx-remote'
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faLink } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import date from 'date-and-time'
|
|
||||||
import Giscus from '@giscus/react'
|
|
||||||
import { useTheme } from 'next-themes'
|
|
||||||
|
|
||||||
import 'katex/dist/katex.min.css'
|
|
||||||
|
|
||||||
import { Head } from 'components/Head'
|
|
||||||
import { Header } from 'components/Header'
|
|
||||||
import type { FooterProps } from 'components/Footer'
|
|
||||||
import { Footer } from 'components/Footer'
|
|
||||||
import type { Post } from 'utils/blog'
|
|
||||||
|
|
||||||
interface BlogPostPageProps extends FooterProps {
|
|
||||||
post: Post
|
|
||||||
}
|
|
||||||
|
|
||||||
const Heading = (
|
|
||||||
props: React.DetailedHTMLProps<
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>,
|
|
||||||
HTMLHeadingElement
|
|
||||||
>
|
|
||||||
): JSX.Element => {
|
|
||||||
const { children, id = '' } = props
|
|
||||||
return (
|
|
||||||
<h2 {...props} className='group'>
|
|
||||||
<Link
|
|
||||||
href={`#${id}`}
|
|
||||||
className='invisible !text-black group-hover:visible dark:!text-white'
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon className='mr-2 inline h-4 w-4' icon={faLink} />
|
|
||||||
</Link>
|
|
||||||
{children}
|
|
||||||
</h2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const BlogPostPage: NextPage<BlogPostPageProps> = (props) => {
|
|
||||||
const { version, post } = props
|
|
||||||
|
|
||||||
const { theme = 'dark' } = useTheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head
|
|
||||||
title={`${post.frontmatter.title} | Théo LUDWIG`}
|
|
||||||
description={post.frontmatter.description}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Header />
|
|
||||||
<main className='break-wrap-words flex flex-1 flex-col flex-wrap items-center'>
|
|
||||||
<div className='my-10 flex flex-col items-center text-center'>
|
|
||||||
<h1 className='text-3xl font-semibold'>{post.frontmatter.title}</h1>
|
|
||||||
<p className='mt-2' data-cy='blog-post-date'>
|
|
||||||
{date.format(new Date(post.frontmatter.publishedOn), 'DD/MM/YYYY')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='prose mb-10'>
|
|
||||||
<div className='px-8'>
|
|
||||||
<MDXRemote
|
|
||||||
{...post.source}
|
|
||||||
components={{
|
|
||||||
h1: Heading,
|
|
||||||
h2: Heading,
|
|
||||||
h3: Heading,
|
|
||||||
h4: Heading,
|
|
||||||
h5: Heading,
|
|
||||||
h6: Heading,
|
|
||||||
img: (properties) => {
|
|
||||||
const { src = '', alt = 'Blog Image' } = properties
|
|
||||||
const source = src.replace('../public/', '/')
|
|
||||||
return (
|
|
||||||
<span className='flex flex-col items-center justify-center'>
|
|
||||||
<Image
|
|
||||||
src={source}
|
|
||||||
alt={alt}
|
|
||||||
width={1000}
|
|
||||||
height={1000}
|
|
||||||
className='h-auto w-auto'
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
a: (props) => {
|
|
||||||
const { href = '' } = props
|
|
||||||
if (href.startsWith('#')) {
|
|
||||||
return <a {...props} />
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a target='_blank' rel='noopener noreferrer' {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Giscus
|
|
||||||
id='comments'
|
|
||||||
repo='theoludwig/theoludwig'
|
|
||||||
repoId='MDEwOlJlcG9zaXRvcnkzNTg5NDg1NDQ='
|
|
||||||
category='General'
|
|
||||||
categoryId='DIC_kwDOFWUewM4CQ_WK'
|
|
||||||
mapping='pathname'
|
|
||||||
reactionsEnabled='1'
|
|
||||||
emitMetadata='0'
|
|
||||||
inputPosition='top'
|
|
||||||
theme={theme}
|
|
||||||
lang='en'
|
|
||||||
loading='lazy'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<Footer version={version} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<BlogPostPageProps> = async (
|
|
||||||
context
|
|
||||||
) => {
|
|
||||||
const slug = context?.params?.['slug']
|
|
||||||
const { getPostBySlug } = await import('utils/blog')
|
|
||||||
const post = await getPostBySlug(slug)
|
|
||||||
if (post == null || (post != null && !post.frontmatter.isPublished)) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
destination: '/404',
|
|
||||||
permanent: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { readPackage } = await import('read-pkg')
|
|
||||||
const { version } = await readPackage()
|
|
||||||
return { props: { version, post } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticPaths: GetStaticPaths = async () => {
|
|
||||||
const { getPosts } = await import('utils/blog')
|
|
||||||
const posts = await getPosts()
|
|
||||||
return {
|
|
||||||
paths: posts.map((post) => {
|
|
||||||
return { params: { slug: post.slug } }
|
|
||||||
}),
|
|
||||||
fallback: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BlogPostPage
|
|
@ -1,81 +0,0 @@
|
|||||||
import type { GetStaticProps, NextPage } from 'next'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import date from 'date-and-time'
|
|
||||||
|
|
||||||
import { Head } from 'components/Head'
|
|
||||||
import { Header } from 'components/Header'
|
|
||||||
import type { FooterProps } from 'components/Footer'
|
|
||||||
import { Footer } from 'components/Footer'
|
|
||||||
import { ShadowContainer } from 'components/design/ShadowContainer'
|
|
||||||
import type { PostMetadata } from 'utils/blog'
|
|
||||||
|
|
||||||
const blogDescription =
|
|
||||||
'The latest news about my journey of learning computer science.'
|
|
||||||
|
|
||||||
interface BlogPageProps extends FooterProps {
|
|
||||||
posts: PostMetadata[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const BlogPage: NextPage<BlogPageProps> = (props) => {
|
|
||||||
const { version, posts } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head title='Blog | Théo LUDWIG' description={blogDescription} />
|
|
||||||
|
|
||||||
<Header />
|
|
||||||
<main className='flex flex-1 flex-col flex-wrap items-center'>
|
|
||||||
<div className='mt-10 flex flex-col items-center'>
|
|
||||||
<h1 className='text-4xl font-semibold'>Blog</h1>
|
|
||||||
<p className='mt-6 text-center' data-cy='blog-post-date'>
|
|
||||||
{blogDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex w-full items-center justify-center p-8'>
|
|
||||||
<div className='w-[1600px]' data-cy='blog-posts'>
|
|
||||||
{posts.map((post, index) => {
|
|
||||||
const postPublishedOn = date.format(
|
|
||||||
new Date(post.frontmatter.publishedOn),
|
|
||||||
'DD/MM/YYYY'
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
key={index}
|
|
||||||
locale='en'
|
|
||||||
data-cy={post.slug}
|
|
||||||
>
|
|
||||||
<ShadowContainer className='cursor-pointer p-6 transition duration-200 ease-in-out hover:-translate-y-2'>
|
|
||||||
<h2
|
|
||||||
data-cy='blog-post-title'
|
|
||||||
className='text-xl font-semibold'
|
|
||||||
>
|
|
||||||
{post.frontmatter.title}
|
|
||||||
</h2>
|
|
||||||
<p data-cy='blog-post-date' className='mt-2'>
|
|
||||||
{postPublishedOn}
|
|
||||||
</p>
|
|
||||||
<p data-cy='blog-post-description' className='mt-3'>
|
|
||||||
{post.frontmatter.description}
|
|
||||||
</p>
|
|
||||||
</ShadowContainer>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<Footer version={version} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<BlogPageProps> = async () => {
|
|
||||||
const { readPackage } = await import('read-pkg')
|
|
||||||
const { getPosts } = await import('utils/blog')
|
|
||||||
const posts = await getPosts()
|
|
||||||
const { version } = await readPackage()
|
|
||||||
return { props: { version, posts } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BlogPage
|
|
@ -1,81 +0,0 @@
|
|||||||
import type { GetStaticProps, NextPage } from 'next'
|
|
||||||
import useTranslation from 'next-translate/useTranslation'
|
|
||||||
|
|
||||||
import { RevealFade } from 'components/design/RevealFade'
|
|
||||||
import { Section } from 'components/design/Section'
|
|
||||||
import { Head } from 'components/Head'
|
|
||||||
import { Interests } from 'components/Interests'
|
|
||||||
import { Portfolio } from 'components/Portfolio'
|
|
||||||
import { Profile } from 'components/Profile'
|
|
||||||
import { SocialMediaList } from 'components/Profile/SocialMediaList'
|
|
||||||
import { Skills } from 'components/Skills'
|
|
||||||
import { OpenSource } from 'components/OpenSource'
|
|
||||||
import { Header } from 'components/Header'
|
|
||||||
import type { FooterProps } from 'components/Footer'
|
|
||||||
import { Footer } from 'components/Footer'
|
|
||||||
|
|
||||||
interface HomeProps extends FooterProps {}
|
|
||||||
|
|
||||||
const Home: NextPage<HomeProps> = (props) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { version } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head />
|
|
||||||
|
|
||||||
<Header showLanguage />
|
|
||||||
<main className='flex flex-col md:mx-auto md:max-w-4xl lg:max-w-7xl'>
|
|
||||||
<Section isMain id='about'>
|
|
||||||
<Profile />
|
|
||||||
<SocialMediaList />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<RevealFade>
|
|
||||||
<Section id='interests' heading={t('home:interests.title')}>
|
|
||||||
<Interests />
|
|
||||||
</Section>
|
|
||||||
</RevealFade>
|
|
||||||
|
|
||||||
<RevealFade>
|
|
||||||
<Section
|
|
||||||
id='skills'
|
|
||||||
heading={t('home:skills.title')}
|
|
||||||
withoutShadowContainer
|
|
||||||
>
|
|
||||||
<Skills />
|
|
||||||
</Section>
|
|
||||||
</RevealFade>
|
|
||||||
|
|
||||||
<RevealFade>
|
|
||||||
<Section
|
|
||||||
id='portfolio'
|
|
||||||
heading={t('home:portfolio.title')}
|
|
||||||
withoutShadowContainer
|
|
||||||
>
|
|
||||||
<Portfolio />
|
|
||||||
</Section>
|
|
||||||
</RevealFade>
|
|
||||||
|
|
||||||
<RevealFade>
|
|
||||||
<Section
|
|
||||||
id='open-source'
|
|
||||||
heading='Open source'
|
|
||||||
withoutShadowContainer
|
|
||||||
>
|
|
||||||
<OpenSource />
|
|
||||||
</Section>
|
|
||||||
</RevealFade>
|
|
||||||
</main>
|
|
||||||
<Footer version={version} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
|
|
||||||
const { readPackage } = await import('read-pkg')
|
|
||||||
const { version } = await readPackage()
|
|
||||||
return { props: { version } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Home
|
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 659 B |
@ -1,9 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
const tailwindConfig = {
|
const tailwindConfig = {
|
||||||
content: [
|
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
|
||||||
'./pages/**/*.{js,ts,jsx,tsx}',
|
|
||||||
'./components/**/*.{js,ts,jsx,tsx}'
|
|
||||||
],
|
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
@ -17,8 +17,13 @@
|
|||||||
"incremental": true,
|
"incremental": true,
|
||||||
"exactOptionalPropertyTypes": false,
|
"exactOptionalPropertyTypes": false,
|
||||||
"verbatimModuleSyntax": false,
|
"verbatimModuleSyntax": false,
|
||||||
"isolatedModules": true
|
"isolatedModules": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
8
utils/constants.ts
Normal file
8
utils/constants.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** How long in milliseconds, until the cookie expires (10 years). */
|
||||||
|
export const COOKIE_MAX_AGE = 10 * 365.25 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export const AVAILABLE_LOCALES = ['en-US', 'fr-FR'] as const
|
||||||
|
|
||||||
|
export type AvailableLocale = (typeof AVAILABLE_LOCALES)[number]
|
||||||
|
|
||||||
|
export const DEFAULT_LOCALE = 'en-US' satisfies AvailableLocale
|
Loading…
Reference in New Issue
Block a user