1
1
mirror of https://github.com/theoludwig/theoludwig.git synced 2024-09-09 17:45:53 +02: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:
Théo LUDWIG 2023-07-31 19:06:46 +02:00
parent 5640f1b434
commit 6b29ce9b15
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
61 changed files with 755 additions and 787 deletions

21
app/error.tsx Normal file
View 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
View 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
View 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
View 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
View 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

View File

@ -1,45 +1,36 @@
import useTranslation from 'next-translate/useTranslation'
import Link from 'next/link'
import type { FooterProps } from './Footer'
import { Footer } from './Footer'
import { Header } from './Header'
import { getI18n } from '@/i18n/i18n.server'
export interface ErrorPageProps extends FooterProps {
export interface ErrorPageProps {
statusCode: number
message: string
}
export const ErrorPage: React.FC<ErrorPageProps> = (props) => {
const { message, statusCode, version } = props
const { t } = useTranslation()
const { message, statusCode } = props
const i18n = getI18n()
return (
<>
<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'>
{t('errors:error')}{' '}
<span
className='text-yellow dark:text-yellow-dark'
data-cy='status-code'
>
{statusCode}
</span>
</h1>
<p className='text-center text-lg'>
{message}{' '}
<Link
href='/'
className='text-yellow hover:underline dark:text-yellow-dark'
>
{t('errors:return-to-home-page')}
</Link>
</p>
</main>
<Footer version={version} />
</div>
</>
<main className='flex flex-col flex-1 items-center justify-center'>
<h1 className='my-6 text-4xl font-semibold'>
{i18n.translate('errors.error')}{' '}
<span
className='text-yellow dark:text-yellow-dark'
data-cy='status-code'
>
{statusCode}
</span>
</h1>
<p className='text-center text-lg'>
{message}{' '}
<Link
href='/'
className='text-yellow hover:underline dark:text-yellow-dark'
>
{i18n.translate('errors.return-to-home-page')}
</Link>
</p>
</main>
)
}

View File

@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>
</>
)
}

View 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>
</>
)
}

View File

@ -1,17 +1,22 @@
'use client'
import { useCallback, useEffect, useState, useRef } from 'react'
import useTranslation from 'next-translate/useTranslation'
import setLanguage from 'next-translate/setLanguage'
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 { 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 languageClickRef = useRef<HTMLDivElement | null>(null)
@ -38,42 +43,48 @@ export const Language: React.FC = () => {
}
}, [])
const handleLanguage = async (language: string): Promise<void> => {
await setLanguage(language, false)
const handleLocale = async (locale: string): Promise<void> => {
const { setLocale } = await import('@/i18n/i18n.server')
setLocale(locale)
}
return (
<div className='flex cursor-pointer flex-col items-center justify-center'>
<div
ref={languageClickRef}
data-cy='language-click'
data-cy='locale-click'
className='mr-5 flex items-center'
onClick={handleHiddenMenu}
>
<LanguageFlag language={currentLanguage} />
<LocaleFlag
locale={currentLocale}
cookiesStore={cookiesStore?.toString()}
/>
<Arrow />
</div>
<ul
data-cy='languages-list'
data-cy='locales-list'
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 }
)}
>
{i18n.locales.map((language, index) => {
if (language === currentLanguage) {
return <></>
}
{AVAILABLE_LOCALES.filter((locale) => {
return locale !== currentLocale
}).map((locale) => {
return (
<li
key={index}
className='flex h-12 w-full items-center justify-center pl-2 hover:bg-[#4f545c] hover:bg-opacity-20'
key={locale}
className='flex h-12 w-full items-center justify-center hover:bg-[#4f545c] hover:bg-opacity-20'
onClick={async () => {
return await handleLanguage(language)
return await handleLocale(locale)
}}
>
<LanguageFlag language={language} />
<LocaleFlag
locale={locale}
cookiesStore={cookiesStore?.toString()}
/>
</li>
)
})}

View File

@ -71,7 +71,7 @@ export const SwitchTheme: React.FC = () => {
data-cy='switch-theme-input'
type='checkbox'
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
/>
</div>

View File

@ -1,15 +1,21 @@
import { cookies } from 'next/headers'
import Link from 'next/link'
import Image from 'next/image'
import { Language } from './Language'
import { getI18n } from '@/i18n/i18n.server'
import { Locales } from './Locales'
import { SwitchTheme } from './SwitchTheme'
export interface HeaderProps {
showLanguage?: boolean
showLocale?: boolean
}
export const Header: React.FC<HeaderProps> = (props) => {
const { showLanguage = false } = props
const { showLocale = false } = props
const cookiesStore = cookies()
const i18n = getI18n()
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'>
@ -38,7 +44,12 @@ export const Header: React.FC<HeaderProps> = (props) => {
Blog
</Link>
</div>
{showLanguage ? <Language /> : null}
{showLocale ? (
<Locales
currentLocale={i18n.locale}
cookiesStore={cookiesStore.toString()}
/>
) : null}
<SwitchTheme />
</div>
</header>

View File

@ -1,19 +1,18 @@
import useTranslation from 'next-translate/useTranslation'
import { getI18n } from '@/i18n/i18n.server'
import type { InterestParagraphProps } from './InterestParagraph'
import { InterestParagraph } from './InterestParagraph'
import { InterestsList } from './InterestsList'
export const Interests: React.FC = () => {
const { t } = useTranslation()
const i18n = getI18n()
const paragraphs: InterestParagraphProps[] = t(
'home:interests.paragraphs',
{},
{
returnObjects: true
}
let paragraphs = i18n.translate<InterestParagraphProps[]>(
'home.interests.paragraphs'
)
if (!Array.isArray(paragraphs)) {
paragraphs = []
}
return (
<div className='max-w-full'>

View 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;
}

View 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>
)
}

View File

@ -1,13 +1,15 @@
import useTranslation from 'next-translate/useTranslation'
import { getI18n } from '@/i18n/i18n.server'
import { Repository } from './Repository'
export const OpenSource: React.FC = () => {
const { t } = useTranslation()
const i18n = getI18n()
return (
<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'>
<Repository
name='nodejs/node'

View File

@ -1,18 +1,15 @@
import useTranslation from 'next-translate/useTranslation'
import { getI18n } from '@/i18n/i18n.server'
import type { PortfolioItemProps } from './PortfolioItem'
import { PortfolioItem } from './PortfolioItem'
export const Portfolio: React.FC = () => {
const { t } = useTranslation('home')
const i18n = getI18n()
const items: PortfolioItemProps[] = t(
'home:portfolio.items',
{},
{
returnObjects: true
}
)
let items = i18n.translate<PortfolioItemProps[]>('home.portfolio.items')
if (!Array.isArray(items)) {
items = []
}
return (
<div className='flex w-full flex-wrap justify-center px-3'>

View File

@ -1,12 +1,12 @@
import useTranslation from 'next-translate/useTranslation'
import { getI18n } from '@/i18n/i18n.server'
export const ProfileDescriptionBottom: React.FC = () => {
const { t, lang } = useTranslation()
const i18n = getI18n()
return (
<p className='mb-8 mt-8 text-base font-normal text-gray dark:text-gray-dark'>
{t('home:about.description-bottom')}
{lang === 'fr' ? (
{i18n.translate('home.about.description-bottom')}
{i18n.locale === 'fr-FR' ? (
<>
<br />
<br />

View File

@ -1,14 +1,16 @@
import useTranslation from 'next-translate/useTranslation'
import { getI18n } from '@/i18n/i18n.server'
export const ProfileInformation: React.FC = () => {
const { t } = useTranslation()
const i18n = getI18n()
return (
<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'>
Théo LUDWIG
</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>
)
}

View File

@ -1,14 +1,21 @@
'use client'
import useTranslation from 'next-translate/useTranslation'
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 { ProfileItem } from './ProfileItem'
export const ProfileList: React.FC = () => {
const { t } = useTranslation('home')
export interface ProfileListProps {
cookiesStore: CookiesStore
}
export const ProfileList = (props: ProfileListProps): JSX.Element => {
const { cookiesStore } = props
const i18n = useI18n(cookiesStore)
const age = useMemo(() => {
return getAge(BIRTH_DATE)
@ -17,14 +24,19 @@ export const ProfileList: React.FC = () => {
return (
<ul className='m-0 list-none p-0'>
<ProfileItem
title={t('home:about.pronouns')}
value={t('home:about.pronouns-value')}
title={i18n.translate('home.about.pronouns')}
value={i18n.translate('home.about.pronouns-value')}
/>
<ProfileItem
title={t('home:about.birth-date')}
value={`${BIRTH_DATE_STRING} (${age} ${t('home:about.years-old')})`}
title={i18n.translate('home.about.birth-date')}
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
title='Email'
value='contact@theoludwig.fr'

View File

@ -1,15 +1,19 @@
import { cookies } from 'next/headers'
import { ProfileDescriptionBottom } from './ProfileDescriptionBottom'
import { ProfileInformation } from './ProfileInfo'
import { ProfileList } from './ProfileList'
import { ProfileLogo } from './ProfileLogo'
export const Profile: React.FC = () => {
const cookiesStore = cookies()
return (
<div className='flex flex-col items-center justify-center px-10 pt-2 md:flex-row md:pt-10'>
<ProfileLogo />
<div>
<ProfileInformation />
<ProfileList />
<ProfileList cookiesStore={cookiesStore.toString()} />
<ProfileDescriptionBottom />
</div>
</div>

15
components/Providers.tsx Normal file
View 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>
)
}

View File

@ -1,6 +1,8 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useTheme } from 'next-themes'
import Image from 'next/image'
import { useMemo } from 'react'
import type { SkillName } from './skills'
import { skills } from './skills'
@ -11,18 +13,31 @@ export interface SkillComponentProps {
export const SkillComponent: React.FC<SkillComponentProps> = (props) => {
const { skill } = props
const skillProperties = skills[skill]
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
useEffect(() => {
setMounted(true)
}, [])
const image = useMemo(() => {
if (typeof skillProperties.image === 'string') {
return skillProperties.image
}
if (!mounted) {
return skillProperties.image.dark
}
if (theme === 'light') {
return skillProperties.image.light
}
return skillProperties.image.dark
}, [skillProperties, theme])
}, [skillProperties, theme, mounted])
if (!mounted) {
return null
}
return (
<a

View File

@ -1,14 +1,14 @@
import useTranslation from 'next-translate/useTranslation'
import { getI18n } from '@/i18n/i18n.server'
import { SkillComponent } from './Skill'
import { SkillsSection } from './SkillsSection'
export const Skills: React.FC = () => {
const { t } = useTranslation()
const i18n = getI18n()
return (
<>
<SkillsSection title={t('home:skills.languages')}>
<SkillsSection title={i18n.translate('home.skills.languages')}>
<SkillComponent skill='TypeScript' />
<SkillComponent skill='Python' />
<SkillComponent skill='C/C++' />
@ -29,7 +29,7 @@ export const Skills: React.FC = () => {
<SkillComponent skill='PostgreSQL' />
</SkillsSection>
<SkillsSection title={t('home:skills.software-tools')}>
<SkillsSection title={i18n.translate('home.skills.software-tools')}>
<SkillComponent skill='GNU/Linux' />
<SkillComponent skill='Arch Linux' />
<SkillComponent skill='Visual Studio Code' />

View File

@ -6,7 +6,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import date from 'date-and-time'
const jsonCurriculumVitaeURL = new URL(
'../curriculum-vitae.jsonc',
'./curriculum-vitae.jsonc',
import.meta.url
)
const dataCurriculumVitaeStringJSON = await fs.promises.readFile(

View File

@ -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}`
)
})
})

View File

@ -37,24 +37,24 @@ describe('Common > Header', () => {
})
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('[data-cy=language-flag-text]').contains('EN')
cy.get('[data-cy=languages-list]').should('not.be.visible')
cy.get('[data-cy=language-click]').click()
cy.get('[data-cy=languages-list]').should('be.visible')
cy.get('[data-cy=languages-list] > li:first-child').contains('FR').click()
cy.get('[data-cy=languages-list]').should('not.be.visible')
cy.get('[data-cy=language-flag-text]').contains('FR')
cy.get('[data-cy=locale-flag-text]').contains('EN')
cy.get('[data-cy=locales-list]').should('not.be.visible')
cy.get('[data-cy=locale-click]').click()
cy.get('[data-cy=locales-list]').should('be.visible')
cy.get('[data-cy=locales-list] > li:first-child').contains('FR').click()
cy.get('[data-cy=locales-list]').should('not.be.visible')
cy.get('[data-cy=locale-flag-text]').contains('FR')
cy.get('h1').contains('Théo LUDWIG')
})
it('should close the language list menu when clicking outside', () => {
cy.get('[data-cy=languages-list]').should('not.be.visible')
cy.get('[data-cy=language-click]').click()
cy.get('[data-cy=languages-list]').should('be.visible')
it('should close the locale list menu when clicking outside', () => {
cy.get('[data-cy=locales-list]').should('not.be.visible')
cy.get('[data-cy=locale-click]').click()
cy.get('[data-cy=locales-list]').should('be.visible')
cy.get('h1').click()
cy.get('[data-cy=languages-list]').should('not.be.visible')
cy.get('[data-cy=locales-list]').should('not.be.visible')
})
})
})

View File

@ -1,7 +1,7 @@
describe('Page /blog/[slug]', () => {
it('should displays the first blog post (`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('.prose a:visible').should('have.attr', 'target', '_blank')
})

View File

@ -1,10 +0,0 @@
{
"locales": ["en", "fr"],
"defaultLocale": "en",
"pages": {
"*": ["common"],
"/": ["home"],
"/404": ["errors"],
"/500": ["errors"]
}
}

12
i18n/i18n.client.ts Normal file
View 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
View 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
View 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
)

View File

@ -1,6 +1,6 @@
{
"english": "English",
"french": "French",
"en-US": "English",
"fr-FR": "French",
"all-rights-reserved": "All rights reserved",
"home": "Home"
}

View File

@ -1,6 +1,6 @@
{
"english": "Anglais",
"french": "Français",
"en-US": "Anglais",
"fr-FR": "Français",
"all-rights-reserved": "Tous droits réservés",
"home": "Accueil"
}

53
middleware.ts Normal file
View 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).*)'
]
}

View File

@ -1,9 +1,10 @@
const nextTranslate = require('next-translate-plugin')
/** @type {import("next").NextConfig} */
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone'
output: 'standalone',
experimental: {
serverActions: true
}
}
module.exports = nextTranslate(nextConfig)
module.exports = nextConfig

185
package-lock.json generated
View File

@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@fontsource/montserrat": "5.0.5",
"@formatjs/intl-localematcher": "0.4.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
@ -19,11 +20,12 @@
"date-and-time": "3.0.2",
"gray-matter": "4.0.3",
"html-react-parser": "4.2.0",
"i18n-js": "4.3.0",
"katex": "0.16.8",
"negotiator": "0.6.3",
"next": "13.4.12",
"next-mdx-remote": "4.4.1",
"next-themes": "0.2.1",
"next-translate": "2.5.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"read-pkg": "8.0.0",
@ -45,6 +47,7 @@
"@semantic-release/git": "10.0.1",
"@tailwindcss/typography": "0.5.9",
"@tsconfig/strictest": "2.0.1",
"@types/negotiator": "0.6.1",
"@types/node": "20.4.5",
"@types/react": "18.2.17",
"@types/unist": "3.0.0",
@ -54,7 +57,7 @@
"curriculum-vitae": "file:./curriculum-vitae",
"cypress": "12.17.2",
"editorconfig-checker": "5.1.1",
"eslint": "8.45.0",
"eslint": "8.46.0",
"eslint-config-conventions": "11.0.1",
"eslint-config-next": "13.4.12",
"eslint-config-prettier": "8.9.0",
@ -67,7 +70,6 @@
"lint-staged": "13.2.3",
"markdownlint-cli2": "0.8.1",
"markdownlint-rule-relative-links": "2.1.0",
"next-translate-plugin": "2.5.2",
"postcss": "8.4.27",
"prettier": "3.0.0",
"prettier-plugin-tailwindcss": "0.4.1",
@ -927,9 +929,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.0.tgz",
"integrity": "sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.1.tgz",
"integrity": "sha512-9t7ZA7NGGK8ckelF0PQCfcxIUzs1Md5rrO6U/c+FIQNanea5UZC0wqKXH4vHBccmu4ZJgZ2idtPeW7+Q2npOEA==",
"dev": true,
"dependencies": {
"ajv": "^6.12.4",
@ -972,9 +974,9 @@
"dev": true
},
"node_modules/@eslint/js": {
"version": "8.44.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.44.0.tgz",
"integrity": "sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==",
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.46.0.tgz",
"integrity": "sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==",
"dev": true,
"engines": {
"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",
"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": {
"version": "6.4.0",
"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",
"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": {
"version": "20.4.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz",
@ -3345,6 +3361,14 @@
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@ -3462,9 +3486,9 @@
}
},
"node_modules/browserslist": {
"version": "4.21.9",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz",
"integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==",
"version": "4.21.10",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz",
"integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==",
"dev": true,
"funding": [
{
@ -3481,9 +3505,9 @@
}
],
"dependencies": {
"caniuse-lite": "^1.0.30001503",
"electron-to-chromium": "^1.4.431",
"node-releases": "^2.0.12",
"caniuse-lite": "^1.0.30001517",
"electron-to-chromium": "^1.4.477",
"node-releases": "^2.0.13",
"update-browserslist-db": "^1.0.11"
},
"bin": {
@ -3646,9 +3670,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001517",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz",
"integrity": "sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==",
"version": "1.0.30001518",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001518.tgz",
"integrity": "sha512-rup09/e3I0BKjncL+FesTayKtPrdwKhUufQFd3riFw1hHg8JmIFoInYfB102cFcY/pPgGmdyl/iy+jgiDi2vdA==",
"funding": [
{
"type": "opencollective",
@ -5134,9 +5158,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.475",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.475.tgz",
"integrity": "sha512-mTye5u5P98kSJO2n7zYALhpJDmoSQejIGya0iR01GpoRady8eK3bw7YHHnjA1Rfi4ZSLdpuzlAC7Zw+1Zu7Z6A==",
"version": "1.4.477",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.477.tgz",
"integrity": "sha512-shUVy6Eawp33dFBFIoYbIwLHrX0IZ857AlH9ug2o4rvbWmpaCUdBpQ5Zw39HRrfzAFm4APJE9V+E2A/WB0YqJw==",
"dev": true
},
"node_modules/emoji-regex": {
@ -5167,9 +5191,9 @@
}
},
"node_modules/enquirer": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.0.tgz",
"integrity": "sha512-ehu97t6FTYK2I3ZYtnp0BZ9vt0mvEL/cnHBds7Ct6jo9VX1VIkiFhOvVRWh6eblQqd7KOoICIQV+syZ3neXO/Q==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"dev": true,
"dependencies": {
"ansi-colors": "^4.1.1",
@ -5473,27 +5497,27 @@
}
},
"node_modules/eslint": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.45.0.tgz",
"integrity": "sha512-pd8KSxiQpdYRfYa9Wufvdoct3ZPQQuVuU5O6scNgMuOMYuxvH0IGaYK0wUFjo4UYYQQCUndlXiMbnxopwvvTiw==",
"version": "8.46.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.46.0.tgz",
"integrity": "sha512-cIO74PvbW0qU8e0mIvk5IV3ToWdCq5FYG6gWPHHkx6gNdjlbAYvtfHmlCMXxjcoVaIdwy/IAt3+mDkZkfvb2Dg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.4.0",
"@eslint/eslintrc": "^2.1.0",
"@eslint/js": "8.44.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.1",
"@eslint/js": "^8.46.0",
"@humanwhocodes/config-array": "^0.11.10",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"ajv": "^6.10.0",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"debug": "^4.3.2",
"doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^7.2.0",
"eslint-visitor-keys": "^3.4.1",
"espree": "^9.6.0",
"eslint-scope": "^7.2.2",
"eslint-visitor-keys": "^3.4.2",
"espree": "^9.6.1",
"esquery": "^1.4.2",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@ -5944,9 +5968,9 @@
}
},
"node_modules/eslint-plugin-react": {
"version": "7.33.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.0.tgz",
"integrity": "sha512-qewL/8P34WkY8jAqdQxsiL82pDUeT7nhs8IsuXgfgnsEloKCT4miAV9N9kGtx7/KM9NH/NCGUE7Edt9iGxLXFw==",
"version": "7.33.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.1.tgz",
"integrity": "sha512-L093k0WAMvr6VhNwReB8VgOq5s2LesZmrpPdKz/kZElQDzqS7G7+DnKoqT+w4JwuiGeAhAvHO0fvy0Eyk4ejDA==",
"dev": true,
"dependencies": {
"array-includes": "^3.1.6",
@ -6070,9 +6094,9 @@
}
},
"node_modules/eslint-scope": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.1.tgz",
"integrity": "sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==",
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dev": true,
"dependencies": {
"esrecurse": "^4.3.0",
@ -6086,9 +6110,9 @@
}
},
"node_modules/eslint-visitor-keys": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz",
"integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.2.tgz",
"integrity": "sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@ -7791,6 +7815,15 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -8678,9 +8711,9 @@
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.4.tgz",
"integrity": "sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==",
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
"integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
"dev": true,
"dependencies": {
"array-includes": "^3.1.6",
@ -9405,6 +9438,11 @@
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
@ -10975,6 +11013,14 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"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": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@ -11066,47 +11112,6 @@
"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": {
"version": "8.4.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
@ -16367,9 +16372,9 @@
}
},
"node_modules/rollup": {
"version": "3.26.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.26.3.tgz",
"integrity": "sha512-7Tin0C8l86TkpcMtXvQu6saWH93nhG3dGQ1/+l5V2TDMceTxO7kDiK6GzbfLWNNxqJXm591PcEZUozZm51ogwQ==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.27.0.tgz",
"integrity": "sha512-aOltLCrYZ0FhJDm7fCqwTjIUEVjWjcydKBV/Zeid6Mn8BWgDCUBBWT5beM5ieForYNo/1ZHuGJdka26kvQ3Gzg==",
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"

View File

@ -30,6 +30,7 @@
},
"dependencies": {
"@fontsource/montserrat": "5.0.5",
"@formatjs/intl-localematcher": "0.4.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
@ -39,11 +40,12 @@
"date-and-time": "3.0.2",
"gray-matter": "4.0.3",
"html-react-parser": "4.2.0",
"i18n-js": "4.3.0",
"katex": "0.16.8",
"negotiator": "0.6.3",
"next": "13.4.12",
"next-mdx-remote": "4.4.1",
"next-themes": "0.2.1",
"next-translate": "2.5.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"read-pkg": "8.0.0",
@ -65,15 +67,17 @@
"@semantic-release/git": "10.0.1",
"@tailwindcss/typography": "0.5.9",
"@tsconfig/strictest": "2.0.1",
"@types/negotiator": "0.6.1",
"@types/node": "20.4.5",
"@types/react": "18.2.17",
"@types/unist": "3.0.0",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"autoprefixer": "10.4.14",
"curriculum-vitae": "file:./curriculum-vitae",
"cypress": "12.17.2",
"editorconfig-checker": "5.1.1",
"eslint": "8.45.0",
"eslint": "8.46.0",
"eslint-config-conventions": "11.0.1",
"eslint-config-next": "13.4.12",
"eslint-config-prettier": "8.9.0",
@ -83,11 +87,9 @@
"eslint-plugin-unicorn": "48.0.1",
"html-w3c-validator": "1.4.0",
"husky": "8.0.3",
"curriculum-vitae": "file:./curriculum-vitae",
"lint-staged": "13.2.3",
"markdownlint-cli2": "0.8.1",
"markdownlint-rule-relative-links": "2.1.0",
"next-translate-plugin": "2.5.2",
"postcss": "8.4.27",
"prettier": "3.0.0",
"prettier-plugin-tailwindcss": "0.4.1",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 659 B

View File

@ -1,9 +1,6 @@
/** @type {import('tailwindcss').Config} */
const tailwindConfig = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}'
],
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {

View File

@ -17,8 +17,13 @@
"incremental": true,
"exactOptionalPropertyTypes": 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"]
}

8
utils/constants.ts Normal file
View 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