Refactor settings menu and implement language changer in next
This commit is contained in:
parent
9919e0c16e
commit
3034605126
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { styled } from 'goober'
|
||||
|
||||
const Center = styled('div')`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
export default Center
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { styled } from 'goober'
|
||||
|
||||
export const OpenButton = styled('button')`
|
||||
.openButton {
|
||||
border: 0;
|
||||
background: none;
|
||||
height: 50px;
|
||||
|
|
@ -19,33 +17,39 @@ 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);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
max-width: initial;
|
||||
min-height: initial;
|
||||
width: initial;
|
||||
height: initial;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
overflow: visible;
|
||||
|
||||
${props => props.$isOpen && `
|
||||
display: block;
|
||||
`}
|
||||
`
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
visibility: hidden;
|
||||
transition: opacity .15s, transform .15s, visibility .15s;
|
||||
|
||||
export const Modal = styled('div')`
|
||||
& > div {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
right: 12px;
|
||||
|
|
@ -58,19 +62,18 @@ export const Modal = styled('div')`
|
|||
box-sizing: border-box;
|
||||
max-width: calc(100% - 20px);
|
||||
box-shadow: 0 3px 6px 0 rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
visibility: hidden;
|
||||
transition: opacity .15s, transform .15s, visibility .15s;
|
||||
|
||||
${props => props.$isOpen && `
|
||||
&[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;
|
||||
`
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
||||
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}
|
||||
return <>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)} title={t('options.name')}
|
||||
><SettingsIcon /></OpenButton>
|
||||
className={makeClass(styles.openButton, isOpen && styles.open)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
title={t<string>('options.name')}
|
||||
><SettingsIcon /></button>
|
||||
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue