Refactor settings menu and implement language changer in next

This commit is contained in:
Ben Grant 2023-05-22 02:28:02 +10:00
parent 9919e0c16e
commit 3034605126
11 changed files with 150 additions and 170 deletions

View file

@ -1,5 +1,6 @@
import { Metadata } from 'next'
import Settings from '/src/components/Settings/Settings'
import { fallbackLng } from '/src/i18n/options'
import { useTranslation } from '/src/i18n/server'
@ -24,10 +25,11 @@ export const metadata: Metadata = {
}
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
const { i18n } = await useTranslation([])
const { resolvedLanguage } = await useTranslation([])
return <html lang={i18n.resolvedLanguage ?? fallbackLng}>
return <html lang={resolvedLanguage ?? fallbackLng}>
<body>
<Settings />
{children}
</body>
</html>

View file

@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
import Button from '/src/components/Button/Button'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import useLocaleUpdateStore from '/src/stores/localeUpdateStore'
import { useStore } from '/src/stores'
import useSettingsStore from '/src/stores/settingsStore'
import { makeClass } from '/src/utils'
@ -23,8 +23,7 @@ interface MonthProps {
const Month = ({ value, onChange }: MonthProps) => {
const { t } = useTranslation('home')
const weekStart = useSettingsStore(state => state.weekStart)
const locale = useLocaleUpdateStore(state => state.locale)
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
const [page, setPage] = useState({
month: dayjs().month(),
@ -45,11 +44,9 @@ const Month = ({ value, onChange }: MonthProps) => {
// Update month view
useEffect(() => {
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
dayjs.updateLocale(locale, { weekStart })
}
dayjs.updateLocale(dayjs.locale(), { weekStart })
setDates(calculateMonth(page, weekStart))
}, [weekStart, page, locale])
}, [weekStart, page])
const handleFinishSelection = useCallback(() => {
if (mode.current === 'add') {

View file

@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import { useStore } from '/src/stores'
import useSettingsStore from '/src/stores/settingsStore'
import { makeClass } from '/src/utils'
@ -21,7 +22,7 @@ interface WeekdaysProps {
const Weekdays = ({ value, onChange }: WeekdaysProps) => {
const { t } = useTranslation('home')
const weekStart = useSettingsStore(state => state.weekStart)
const weekStart = useStore(useSettingsStore, state => state.weekStart) ?? 0
const weekdays = useMemo(() => rotateArray(dayjs.weekdaysShort().map((name, i) => ({
name,

View file

@ -1,9 +0,0 @@
import { styled } from 'goober'
const Center = styled('div')`
display: flex;
align-items: center;
justify-content: center;
`
export default Center

View file

@ -48,7 +48,6 @@ const CreateForm = () => {
const [error, setError] = useState<React.ReactNode>()
const onSubmit: SubmitHandler<Fields> = async values => {
console.log({values}) // TODO:
setIsLoading(true)
setError(undefined)

View file

@ -1,6 +1,4 @@
import { styled } from 'goober'
export const OpenButton = styled('button')`
.openButton {
border: 0;
background: none;
height: 50px;
@ -19,58 +17,63 @@ export const OpenButton = styled('button')`
transition: transform .15s;
padding: 0;
${props => props.$isOpen && `
transform: rotate(-60deg);
`}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@media print {
display: none;
}
`
}
export const Cover = styled('div')`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: none;
.open {
transform: rotate(-60deg);
}
${props => props.$isOpen && `
display: block;
`}
`
export const Modal = styled('div')`
.modal {
position: absolute;
top: 70px;
right: 12px;
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 150;
padding: 10px 18px;
border-radius: 3px;
width: 270px;
box-sizing: border-box;
max-width: calc(100% - 20px);
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
inset: 0;
max-width: initial;
min-height: initial;
width: initial;
height: initial;
padding: 0;
margin: 0;
background: none;
border: none;
overflow: visible;
display: block;
pointer-events: none;
opacity: 0;
transform: translateY(-10px);
visibility: hidden;
transition: opacity .15s, transform .15s, visibility .15s;
${props => props.$isOpen && `
& > div {
position: absolute;
top: 70px;
right: 12px;
background-color: var(--background);
border: 1px solid var(--surface);
z-index: 150;
padding: 10px 18px;
border-radius: 3px;
width: 270px;
box-sizing: border-box;
max-width: calc(100% - 20px);
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
}
&[open] {
pointer-events: all;
opacity: 1;
transform: translateY(0);
visibility: visible;
`}
}
&::backdrop {
background: none;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
@ -78,11 +81,11 @@ export const Modal = styled('div')`
@media print {
display: none;
}
`
}
export const Heading = styled('span')`
.heading {
font-size: 1.5rem;
display: block;
margin: 6px 0;
line-height: 1em;
`
}

View file

@ -1,136 +1,99 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import { Settings as SettingsIcon } from 'lucide-react'
'use client'
import { useCallback, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { maps } from 'hue-map'
import { Settings as SettingsIcon } from 'lucide-react'
import { ToggleField, SelectField } from '/src/components'
import SelectField from '/src/components/SelectField/SelectField'
import ToggleField from '/src/components/ToggleField/ToggleField'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import { languageDetails } from '/src/i18n/options'
import { useStore } from '/src/stores'
import useSettingsStore from '/src/stores/settingsStore'
import { makeClass, unhyphenate } from '/src/utils'
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import styles from './Settings.module.scss'
import {
OpenButton,
Modal,
Heading,
Cover,
} from './Settings.styles'
import locales from '/src/i18n/locales'
import { unhyphenate } from '/src/utils'
import { useRouter } from 'next/router'
// Language specific options
const setDefaults = (lang, store) => {
if (locales[lang]) {
store.setWeekStart(locales[lang].weekStart)
store.setTimeFormat(locales[lang].timeFormat)
}
}
// TODO: add to giraugh tools
const isKeyOfObject = <T extends object>(
key: string | number | symbol,
obj: T,
): key is keyof T => key in obj
const Settings = () => {
const { pathname } = useRouter()
const store = useSettingsStore()
const [isOpen, _setIsOpen] = useState(false)
const { t, i18n } = useTranslation('common')
const setLocale = useLocaleUpdateStore(state => state.setLocale)
const firstControlRef = useRef()
const router = useRouter()
const onEsc = e => {
if (e.key === 'Escape') {
setIsOpen(false)
}
}
const store = useStore(useSettingsStore, state => state)
const setIsOpen = open => {
_setIsOpen(open)
if (open) {
window.setTimeout(() => firstControlRef.current?.focus(), 150)
document.addEventListener('keyup', onEsc, true)
const modalRef = useRef<HTMLDialogElement>(null)
const [isOpen, _setIsOpen] = useState(false)
const setIsOpen = useCallback((shouldOpen: boolean) => {
if (shouldOpen) {
modalRef.current?.showModal()
_setIsOpen(true)
} else {
document.removeEventListener('keyup', onEsc)
modalRef.current?.close()
_setIsOpen(false)
}
}
}, [])
useEffect(() => {
if (Object.keys(locales).includes(i18n.language)) {
locales[i18n.language].import().then(() => {
dayjs.locale(i18n.language)
setLocale(dayjs.locale())
document.documentElement.setAttribute('lang', i18n.language)
})
} else {
setLocale('en')
document.documentElement.setAttribute('lang', 'en')
}
}, [i18n.language, setLocale])
return <>
<button
type="button"
className={makeClass(styles.openButton, isOpen && styles.open)}
onClick={() => setIsOpen(!isOpen)}
title={t<string>('options.name')}
><SettingsIcon /></button>
if (!i18n.options.storedLang) {
setDefaults(i18n.language, store)
i18n.options.storedLang = i18n.language
}
i18n.on('languageChanged', lang => {
setDefaults(lang, store)
})
// Reset scroll on navigation
useEffect(() => window.scrollTo(0, 0), [pathname])
return (
<>
<OpenButton
$isOpen={isOpen}
type="button"
onClick={() => setIsOpen(!isOpen)} title={t('options.name')}
><SettingsIcon /></OpenButton>
<Cover $isOpen={isOpen} onClick={() => setIsOpen(false)} />
<Modal $isOpen={isOpen}>
<Heading>{t('options.name')}</Heading>
<dialog
className={styles.modal}
ref={modalRef}
onClose={() => _setIsOpen(false)}
onClick={() => modalRef.current?.close()}
>
<div onClick={e => e.stopPropagation()}>
<span className={styles.heading}>{t('options.name')}</span>
<ToggleField
label={t('options.weekStart.label')}
name="weekStart"
id="weekStart"
options={{
'Sunday': t('options.weekStart.options.Sunday'),
'Monday': t('options.weekStart.options.Monday'),
}}
value={store.weekStart === 0 ? 'Sunday' : 'Monday'}
onChange={value => store.setWeekStart(value === 'Sunday' ? 0 : 1)}
inputRef={firstControlRef}
value={store?.weekStart === 0 ? 'Sunday' : 'Monday'}
onChange={value => store?.setWeekStart(value === 'Sunday' ? 0 : 1)}
/>
<ToggleField
label={t('options.timeFormat.label')}
name="timeFormat"
id="timeFormat"
options={{
'12h': t('options.timeFormat.options.12h'),
'24h': t('options.timeFormat.options.24h'),
}}
value={store.timeFormat}
onChange={value => store.setTimeFormat(value)}
value={store?.timeFormat ?? '12h'}
onChange={value => store?.setTimeFormat(value)}
/>
<ToggleField
label={t('options.theme.label')}
name="theme"
id="theme"
options={{
'System': t('options.theme.options.System'),
'Light': t('options.theme.options.Light'),
'Dark': t('options.theme.options.Dark'),
}}
value={store.theme}
onChange={value => store.setTheme(value)}
value={store?.theme ?? 'System'}
onChange={value => store?.setTheme(value)}
/>
<SelectField
label={t('options.colormap.label')}
name="colormap"
id="colormap"
options={{
'crabfit': t('options.colormap.classic'),
...Object.fromEntries(Object.keys(maps).sort().map(palette => [
@ -138,22 +101,21 @@ const Settings = () => {
unhyphenate(palette)
])),
}}
small
value={store.colormap}
onChange={event => store.setColormap(event.target.value)}
isSmall
value={store?.colormap}
onChange={event => store?.setColormap(event.target.value)}
/>
<ToggleField
label={t('options.highlight.label')}
name="highlight"
id="highlight"
title={t('options.highlight.title')}
description={t('options.highlight.title')}
options={{
'Off': t('options.highlight.options.Off'),
'On': t('options.highlight.options.On'),
}}
value={store.highlight ? 'On' : 'Off'}
onChange={value => store.setHighlight(value === 'On')}
value={store?.highlight ? 'On' : 'Off'}
onChange={value => store?.setHighlight(value === 'On')}
/>
<SelectField
@ -161,19 +123,26 @@ const Settings = () => {
name="language"
id="language"
options={{
...Object.keys(locales).reduce((ls, l) => {
ls[l] = locales[l].name
return ls
}, {}),
...Object.fromEntries(Object.entries(languageDetails).map(([id, details]) => [id, details.name])),
...process.env.NODE_ENV !== 'production' && { 'cimode': 'DEV' },
}}
small
isSmall
value={i18n.language}
onChange={event => i18n.changeLanguage(event.target.value)}
onChange={e => {
if (isKeyOfObject(e.target.value, languageDetails)) {
store?.setWeekStart(languageDetails[e.target.value].weekStart)
store?.setTimeFormat(languageDetails[e.target.value].timeFormat)
languageDetails[e.target.value]?.import().then(() => {
dayjs.locale(e.target.value)
})
}
i18n.changeLanguage(e.target.value).then(() => router.refresh())
}}
/>
</Modal>
</>
)
</div>
</dialog>
</>
}
export default Settings

View file

@ -3,6 +3,7 @@ import { FieldValues, useController, UseControllerProps } from 'react-hook-form'
import dayjs from 'dayjs'
import { Description, Label, Wrapper } from '/src/components/Field/Field'
import { useStore } from '/src/stores'
import useSettingsStore from '/src/stores/settingsStore'
import styles from './TimeRangeField.module.scss'
@ -67,7 +68,7 @@ interface HandleProps {
}
const Handle = ({ value, onChange, labelPadding }: HandleProps) => {
const timeFormat = useSettingsStore(state => state.timeFormat)
const timeFormat = useStore(useSettingsStore, state => state.timeFormat)
const isMoving = useRef(false)
const rangeRect = useRef({ left: 0, width: 0 })

View file

@ -1,12 +1,14 @@
'use client'
import { initReactI18next, useTranslation as useTranslationHook } from 'react-i18next'
import { cookies } from 'next/dist/client/components/headers' // risky disky (undocumented???)
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import resourcesToBackend from 'i18next-resources-to-backend'
import { getOptions } from './options'
import dayjs from '/src/config/dayjs'
import { cookieName, getOptions, languageDetails } from './options'
i18next
.use(initReactI18next)
@ -16,10 +18,18 @@ i18next
))
.init({
...getOptions(),
lng: undefined,
lng: typeof window === 'undefined' ? cookies().get(cookieName)?.value : undefined,
detection: {
order: ['htmlTag', 'cookie', 'navigator'],
caches: ['localStorage', 'cookie'],
excludeCacheFor: [],
},
})
.then(() => {
// Set dayjs locale
languageDetails[i18next.resolvedLanguage as keyof typeof languageDetails]?.import().then(() => {
dayjs.locale(i18next.resolvedLanguage)
})
})
export const useTranslation: typeof useTranslationHook = (ns, options) => useTranslationHook(ns, options)

View file

@ -30,7 +30,7 @@ interface LanguageDetails {
/** TODO: document */
separator?: string
/** Day.js locale import */
import: () => unknown
import: () => Promise<unknown>
}
export const languageDetails: Record<typeof languages[number], LanguageDetails> = {

View file

@ -1,10 +1,11 @@
import { UseTranslationOptions } from 'react-i18next'
import { cookies, headers } from 'next/headers'
import acceptLanguage from 'accept-language'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { cookieName, fallbackLng, getOptions, languages } from './options'
import dayjs from '/src/config/dayjs'
import { cookieName, fallbackLng, getOptions, languageDetails, languages } from './options'
type Mutable<T> = { -readonly [K in keyof T]: Mutable<T[K]> }
@ -20,14 +21,20 @@ const initI18next = async (language: string, ns: string | string []) => {
return i18nInstance
}
export const useTranslation = async (ns: string | string[], options: UseTranslationOptions = {}) => {
export const useTranslation = async (ns: string | string[], options: { keyPrefix?: string } = {}) => {
const language = cookies().get(cookieName)?.value
?? acceptLanguage.get(headers().get('Accept-Language'))
?? fallbackLng
// Set dayjs locale
languageDetails[language as keyof typeof languageDetails]?.import().then(() => {
dayjs.locale(language)
})
const i18nextInstance = await initI18next(language, ns)
return {
t: i18nextInstance.getFixedT(language, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
i18n: i18nextInstance
i18n: i18nextInstance,
resolvedLanguage: language,
}
}