1
1
mirror of https://github.com/theoludwig/theoludwig.git synced 2025-05-29 22:37:44 +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:
2023-07-31 19:06:46 +02:00
parent 5640f1b434
commit 6b29ce9b15
61 changed files with 755 additions and 787 deletions

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' />