1
1
mirror of https://github.com/theoludwig/theoludwig.git synced 2024-12-08 00:44:30 +01:00

refactor: implement light/dark themes using cookies

This commit is contained in:
Théo LUDWIG 2023-08-01 14:11:46 +02:00
parent e82db952db
commit caa6a90418
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
21 changed files with 117 additions and 117 deletions

View File

@ -1,6 +1,6 @@
{ {
"extends": ["conventions", "next/core-web-vitals", "prettier"], "extends": ["conventions", "next/core-web-vitals", "prettier"],
"plugins": ["prettier", "unicorn"], "plugins": ["prettier"],
"parserOptions": { "parserOptions": {
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },

View File

@ -2,7 +2,7 @@ image: 'gitpod/workspace-full'
tasks: tasks:
- before: 'cp .env.example .env' - before: 'cp .env.example .env'
init: 'npm install' init: 'npm clean-install'
command: 'npm run dev' command: 'npm run dev'
ports: ports:

View File

@ -49,7 +49,7 @@ cd theoludwig
cp .env.example .env cp .env.example .env
# Install # Install
npm install npm clean-install
``` ```
### Local Development environment ### Local Development environment

View File

@ -1,13 +1,14 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import classNames from 'clsx'
import '@fontsource/montserrat/400.css' import '@fontsource/montserrat/400.css'
import '@fontsource/montserrat/600.css' import '@fontsource/montserrat/600.css'
import './globals.css' import './globals.css'
import { Providers } from '@/components/Providers'
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { getI18n } from '@/i18n/i18n.server' import { getI18n } from '@/i18n/i18n.server'
import { getTheme } from '@/theme/theme.server'
const title = 'Théo LUDWIG' const title = 'Théo LUDWIG'
const description = const description =
@ -54,15 +55,23 @@ const RootLayout = (props: RootLayoutProps): JSX.Element => {
const { children } = props const { children } = props
const i18n = getI18n() const i18n = getI18n()
const theme = getTheme()
return ( return (
<html suppressHydrationWarning lang={i18n.locale}> <html
lang={i18n.locale}
className={classNames({
dark: theme === 'dark',
light: theme === 'light'
})}
style={{
colorScheme: theme
}}
>
<body className='bg-white font-headline text-black dark:bg-black dark:text-white'> <body className='bg-white font-headline text-black dark:bg-black dark:text-white'>
<Providers>
<Header showLocale /> <Header showLocale />
{children} {children}
<Footer /> <Footer />
</Providers>
</body> </body>
</html> </html>
) )

View File

@ -1,6 +1,6 @@
import Image from 'next/image' import Image from 'next/image'
import type { CookiesStore } from '@/i18n/i18n.client' import type { CookiesStore } from '@/utils/constants'
import { useI18n } from '@/i18n/i18n.client' import { useI18n } from '@/i18n/i18n.client'
export interface LocaleFlagProps { export interface LocaleFlagProps {

View File

@ -3,8 +3,8 @@
import { useCallback, useEffect, useState, useRef } from 'react' import { useCallback, useEffect, useState, useRef } from 'react'
import classNames from 'clsx' import classNames from 'clsx'
import { AVAILABLE_LOCALES } from '@/utils/constants' import type { Locale as LocaleType, CookiesStore } from '@/utils/constants'
import type { CookiesStore } from '@/i18n/i18n.client' import { LOCALES } from '@/utils/constants'
import { Arrow } from './Arrow' import { Arrow } from './Arrow'
import { LocaleFlag } from './LocaleFlag' import { LocaleFlag } from './LocaleFlag'
@ -43,7 +43,7 @@ export const Locales = (props: LocalesProps): JSX.Element => {
} }
}, []) }, [])
const handleLocale = async (locale: string): Promise<void> => { const handleLocale = async (locale: LocaleType): Promise<void> => {
const { setLocale } = await import('@/i18n/i18n.server') const { setLocale } = await import('@/i18n/i18n.server')
setLocale(locale) setLocale(locale)
} }
@ -70,7 +70,7 @@ export const Locales = (props: LocalesProps): JSX.Element => {
{ hidden: hiddenMenu } { hidden: hiddenMenu }
)} )}
> >
{AVAILABLE_LOCALES.filter((locale) => { {LOCALES.filter((locale) => {
return locale !== currentLocale return locale !== currentLocale
}).map((locale) => { }).map((locale) => {
return ( return (

View File

@ -1,23 +1,22 @@
'use client' 'use client'
import { useEffect, useState } from 'react'
import classNames from 'clsx' import classNames from 'clsx'
import { useTheme } from 'next-themes'
export const SwitchTheme: React.FC = () => { import { useTheme } from '@/theme/theme.client'
const [mounted, setMounted] = useState(false) import type { CookiesStore } from '@/utils/constants'
const { theme, setTheme } = useTheme()
useEffect(() => { export interface SwitchThemeProps {
setMounted(true) cookiesStore: CookiesStore
}, [])
if (!mounted) {
return null
} }
const handleClick = (): void => { export const SwitchTheme = (props: SwitchThemeProps): JSX.Element => {
setTheme(theme === 'dark' ? 'light' : 'dark') const { cookiesStore } = props
const theme = useTheme(cookiesStore)
const handleClick = async (): Promise<void> => {
const { setTheme } = await import('@/theme/theme.server')
const newTheme = theme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
} }
return ( return (

View File

@ -50,7 +50,7 @@ export const Header: React.FC<HeaderProps> = (props) => {
cookiesStore={cookiesStore.toString()} cookiesStore={cookiesStore.toString()}
/> />
) : null} ) : null}
<SwitchTheme /> <SwitchTheme cookiesStore={cookiesStore.toString()} />
</div> </div>
</header> </header>
) )

View File

@ -2,9 +2,9 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import type { CookiesStore } from '@/i18n/i18n.client'
import { useI18n } from '@/i18n/i18n.client' import { useI18n } from '@/i18n/i18n.client'
import { BIRTH_DATE, BIRTH_DATE_STRING, getAge } from '@/utils/getAge' import { BIRTH_DATE, BIRTH_DATE_STRING, getAge } from '@/utils/getAge'
import type { CookiesStore } from '@/utils/constants'
import { ProfileItem } from './ProfileItem' import { ProfileItem } from './ProfileItem'

View File

@ -1,15 +0,0 @@
'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,9 +1,7 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useTheme } from 'next-themes'
import Image from 'next/image' import Image from 'next/image'
import { getTheme } from '@/theme/theme.server'
import type { SkillName } from './skills' import type { SkillName } from './skills'
import { skills } from './skills' import { skills } from './skills'
@ -15,28 +13,17 @@ export const SkillComponent: React.FC<SkillComponentProps> = (props) => {
const { skill } = props const { skill } = props
const skillProperties = skills[skill] const skillProperties = skills[skill]
const [mounted, setMounted] = useState(false)
const { theme } = useTheme()
useEffect(() => { const theme = getTheme()
setMounted(true)
}, [])
const image = useMemo(() => { const getImage = (): string => {
if (typeof skillProperties.image === 'string') { if (typeof skillProperties.image === 'string') {
return skillProperties.image return skillProperties.image
} }
if (!mounted) {
return skillProperties.image.dark
}
if (theme === 'light') { if (theme === 'light') {
return skillProperties.image.light return skillProperties.image.light
} }
return skillProperties.image.dark return skillProperties.image.dark
}, [skillProperties, theme, mounted])
if (!mounted) {
return null
} }
return ( return (
@ -53,7 +40,7 @@ export const SkillComponent: React.FC<SkillComponentProps> = (props) => {
width={64} width={64}
height={64} height={64}
alt={skill} alt={skill}
src={image} src={getImage()}
/> />
<p className='mt-1'>{skill}</p> <p className='mt-1'>{skill}</p>
</div> </div>

View File

@ -1,9 +1,9 @@
import UniversalCookie from 'universal-cookie' import UniversalCookie from 'universal-cookie'
import type { I18n } from 'i18n-js' import type { I18n } from 'i18n-js'
import { i18n } from './i18n' import type { CookiesStore } from '@/utils/constants'
export type CookiesStore = string | object | null | undefined import { i18n } from './i18n'
export const useI18n = (cookiesStore: CookiesStore): I18n => { export const useI18n = (cookiesStore: CookiesStore): I18n => {
const universalCookie = new UniversalCookie(cookiesStore) const universalCookie = new UniversalCookie(cookiesStore)

View File

@ -3,11 +3,12 @@
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import type { I18n } from 'i18n-js' import type { I18n } from 'i18n-js'
import type { Locale } from '@/utils/constants'
import { COOKIE_MAX_AGE } from '@/utils/constants' import { COOKIE_MAX_AGE } from '@/utils/constants'
import { i18n } from './i18n' import { i18n } from './i18n'
export const setLocale = (locale: string): void => { export const setLocale = (locale: Locale): void => {
cookies().set('locale', locale, { cookies().set('locale', locale, {
path: '/', path: '/',
maxAge: COOKIE_MAX_AGE maxAge: COOKIE_MAX_AGE

View File

@ -1,6 +1,7 @@
import { I18n } from 'i18n-js' import { I18n } from 'i18n-js'
import { DEFAULT_LOCALE, AVAILABLE_LOCALES } from '@/utils/constants' import type { Locale } from '@/utils/constants'
import { DEFAULT_LOCALE, LOCALES } from '@/utils/constants'
import commonEnglish from './translations/en-US/common.json' import commonEnglish from './translations/en-US/common.json'
import errorsEnglish from './translations/en-US/errors.json' import errorsEnglish from './translations/en-US/errors.json'
@ -9,14 +10,7 @@ import commonFrench from './translations/fr-FR/common.json'
import errorsFrench from './translations/fr-FR/errors.json' import errorsFrench from './translations/fr-FR/errors.json'
import homeFrench from './translations/fr-FR/home.json' import homeFrench from './translations/fr-FR/home.json'
export const i18nOptions = { const translations = {
defaultLocale: DEFAULT_LOCALE,
availableLocales: AVAILABLE_LOCALES.slice(),
enableFallback: true
}
export const i18n = new I18n(
{
'en-US': { 'en-US': {
common: commonEnglish, common: commonEnglish,
errors: errorsEnglish, errors: errorsEnglish,
@ -27,6 +21,10 @@ export const i18n = new I18n(
errors: errorsFrench, errors: errorsFrench,
home: homeFrench home: homeFrench
} }
}, } satisfies Record<Locale, Record<string, unknown>>
i18nOptions
) export const i18n = new I18n(translations, {
defaultLocale: DEFAULT_LOCALE,
availableLocales: LOCALES.slice(),
enableFallback: true
})

View File

@ -3,38 +3,39 @@ import type { NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher' import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator' import Negotiator from 'negotiator'
import type { AvailableLocale } from '@/utils/constants' import type { Locale, Theme } from '@/utils/constants'
import { import {
COOKIE_MAX_AGE, COOKIE_MAX_AGE,
DEFAULT_LOCALE, DEFAULT_LOCALE,
AVAILABLE_LOCALES DEFAULT_THEME,
LOCALES,
THEMES
} from '@/utils/constants' } from '@/utils/constants'
export const middleware = (request: NextRequest): NextResponse => { export const middleware = (request: NextRequest): NextResponse => {
const response = NextResponse.next() const response = NextResponse.next()
let locale = request.cookies.get('locale')?.value let locale = request.cookies.get('locale')?.value
if ( if (locale == null || !LOCALES.includes(locale as Locale)) {
locale == null ||
!AVAILABLE_LOCALES.includes(locale as AvailableLocale)
) {
const headers = { const headers = {
'accept-language': 'accept-language':
request.headers.get('accept-language') ?? DEFAULT_LOCALE request.headers.get('accept-language') ?? DEFAULT_LOCALE
} }
const languages = new Negotiator({ headers }).languages() const languages = new Negotiator({ headers }).languages()
locale = match(languages, AVAILABLE_LOCALES.slice(), DEFAULT_LOCALE) locale = match(languages, LOCALES.slice(), DEFAULT_LOCALE)
response.cookies.set('locale', locale, { response.cookies.set('locale', locale, {
path: '/', path: '/',
maxAge: COOKIE_MAX_AGE maxAge: COOKIE_MAX_AGE
}) })
} }
const theme = request.cookies.get('theme')?.value ?? 'dark' const theme = request.cookies.get('theme')?.value
response.cookies.set('theme', theme, { if (theme == null || !THEMES.includes(theme as Theme)) {
response.cookies.set('theme', DEFAULT_THEME, {
path: '/', path: '/',
expires: COOKIE_MAX_AGE maxAge: COOKIE_MAX_AGE
}) })
}
return response return response
} }

11
package-lock.json generated
View File

@ -25,7 +25,6 @@
"negotiator": "0.6.3", "negotiator": "0.6.3",
"next": "13.4.12", "next": "13.4.12",
"next-mdx-remote": "4.4.1", "next-mdx-remote": "4.4.1",
"next-themes": "0.2.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"read-pkg": "8.0.0", "read-pkg": "8.0.0",
@ -11102,16 +11101,6 @@
"react-dom": ">=16.x <=18.x" "react-dom": ">=16.x <=18.x"
} }
}, },
"node_modules/next-themes": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz",
"integrity": "sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==",
"peerDependencies": {
"next": "*",
"react": "*",
"react-dom": "*"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.14", "version": "8.4.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",

View File

@ -45,7 +45,6 @@
"negotiator": "0.6.3", "negotiator": "0.6.3",
"next": "13.4.12", "next": "13.4.12",
"next-mdx-remote": "4.4.1", "next-mdx-remote": "4.4.1",
"next-themes": "0.2.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"read-pkg": "8.0.0", "read-pkg": "8.0.0",

9
theme/theme.client.ts Normal file
View File

@ -0,0 +1,9 @@
import UniversalCookie from 'universal-cookie'
import { DEFAULT_THEME } from '@/utils/constants'
import type { CookiesStore, Theme } from '@/utils/constants'
export const useTheme = (cookiesStore: CookiesStore): Theme => {
const universalCookie = new UniversalCookie(cookiesStore)
return universalCookie.get('theme') ?? DEFAULT_THEME
}

21
theme/theme.server.ts Normal file
View File

@ -0,0 +1,21 @@
'use server'
import { cookies } from 'next/headers'
import type { Theme } from '@/utils/constants'
import { COOKIE_MAX_AGE, DEFAULT_THEME, THEMES } from '@/utils/constants'
export const setTheme = (theme: Theme): void => {
cookies().set('theme', theme, {
path: '/',
maxAge: COOKIE_MAX_AGE
})
}
export const getTheme = (): Theme => {
const theme = cookies().get('theme')?.value ?? DEFAULT_THEME
if (THEMES.includes(theme as Theme)) {
return theme as Theme
}
return DEFAULT_THEME
}

View File

@ -1,8 +1,11 @@
/** How long in milliseconds, until the cookie expires (10 years). */ /** How long in milliseconds, until the cookie expires (10 years). */
export const COOKIE_MAX_AGE = 10 * 365.25 * 24 * 60 * 60 * 1000 export const COOKIE_MAX_AGE = 10 * 365.25 * 24 * 60 * 60 * 1000
export type CookiesStore = string | object | null | undefined
export const AVAILABLE_LOCALES = ['en-US', 'fr-FR'] as const export const LOCALES = ['en-US', 'fr-FR'] as const
export type Locale = (typeof LOCALES)[number]
export const DEFAULT_LOCALE = 'en-US' satisfies Locale
export type AvailableLocale = (typeof AVAILABLE_LOCALES)[number] export const THEMES = ['light', 'dark'] as const
export type Theme = (typeof THEMES)[number]
export const DEFAULT_LOCALE = 'en-US' satisfies AvailableLocale export const DEFAULT_THEME = 'dark' satisfies Theme

View File

@ -8,9 +8,8 @@ export const BIRTH_DATE_ISO_8601 =
export const BIRTH_DATE = new Date(BIRTH_DATE_ISO_8601) export const BIRTH_DATE = new Date(BIRTH_DATE_ISO_8601)
/** /**
* Calculates the age of a person based on their birth date * Calculates the age of a person based on their birth date.
* @param birthDate * @param birthDate
* @returns
*/ */
export const getAge = (birthDate: Date): number => { export const getAge = (birthDate: Date): number => {
const today = new Date() const today = new Date()