mirror of
https://github.com/theoludwig/theoludwig.git
synced 2024-11-03 20:11: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,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>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
||||
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>
|
||||
)
|
||||
})}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'>
|
||||
|
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'
|
||||
|
||||
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'
|
||||
|
@ -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'>
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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
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 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
|
||||
|
@ -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' />
|
||||
|
@ -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(
|
||||
|
@ -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', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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')
|
||||
})
|
||||
|
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",
|
||||
"french": "French",
|
||||
"en-US": "English",
|
||||
"fr-FR": "French",
|
||||
"all-rights-reserved": "All rights reserved",
|
||||
"home": "Home"
|
||||
}
|
@ -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
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 = {
|
||||
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,
|
||||
"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"
|
||||
|
10
package.json
10
package.json
@ -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",
|
||||
|
@ -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} */
|
||||
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: {
|
||||
|
@ -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
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