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:
@ -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' />
|
||||
|
Reference in New Issue
Block a user