From 30346051266b159fb686adfef68c4210b451a740 Mon Sep 17 00:00:00 2001 From: Ben Grant Date: Mon, 22 May 2023 02:28:02 +1000 Subject: [PATCH] Refactor settings menu and implement language changer in next --- frontend/src/app/layout.tsx | 6 +- .../CalendarField/components/Month/Month.tsx | 11 +- .../components/Weekdays/Weekdays.tsx | 3 +- frontend/src/components/Center/Center.js | 9 - .../src/components/CreateForm/CreateForm.tsx | 1 - ...ettings.styles.ts => Settings.module.scss} | 79 ++++---- frontend/src/components/Settings/Settings.tsx | 177 ++++++++---------- .../TimeRangeField/TimeRangeField.tsx | 3 +- frontend/src/i18n/client.ts | 14 +- frontend/src/i18n/options.ts | 2 +- frontend/src/i18n/server.ts | 15 +- 11 files changed, 150 insertions(+), 170 deletions(-) delete mode 100644 frontend/src/components/Center/Center.js rename frontend/src/components/Settings/{Settings.styles.ts => Settings.module.scss} (55%) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index c55a3cf..fc35f1b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -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 + return + {children} diff --git a/frontend/src/components/CalendarField/components/Month/Month.tsx b/frontend/src/components/CalendarField/components/Month/Month.tsx index a9c2e90..4ac1147 100644 --- a/frontend/src/components/CalendarField/components/Month/Month.tsx +++ b/frontend/src/components/CalendarField/components/Month/Month.tsx @@ -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') { diff --git a/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx b/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx index e1d13c0..eeba8a5 100644 --- a/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx +++ b/frontend/src/components/CalendarField/components/Weekdays/Weekdays.tsx @@ -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, diff --git a/frontend/src/components/Center/Center.js b/frontend/src/components/Center/Center.js deleted file mode 100644 index a696734..0000000 --- a/frontend/src/components/Center/Center.js +++ /dev/null @@ -1,9 +0,0 @@ -import { styled } from 'goober' - -const Center = styled('div')` - display: flex; - align-items: center; - justify-content: center; -` - -export default Center diff --git a/frontend/src/components/CreateForm/CreateForm.tsx b/frontend/src/components/CreateForm/CreateForm.tsx index d4764cd..d141470 100644 --- a/frontend/src/components/CreateForm/CreateForm.tsx +++ b/frontend/src/components/CreateForm/CreateForm.tsx @@ -48,7 +48,6 @@ const CreateForm = () => { const [error, setError] = useState() const onSubmit: SubmitHandler = async values => { - console.log({values}) // TODO: setIsLoading(true) setError(undefined) diff --git a/frontend/src/components/Settings/Settings.styles.ts b/frontend/src/components/Settings/Settings.module.scss similarity index 55% rename from frontend/src/components/Settings/Settings.styles.ts rename to frontend/src/components/Settings/Settings.module.scss index ad7b62d..30eb3d9 100644 --- a/frontend/src/components/Settings/Settings.styles.ts +++ b/frontend/src/components/Settings/Settings.module.scss @@ -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; -` +} diff --git a/frontend/src/components/Settings/Settings.tsx b/frontend/src/components/Settings/Settings.tsx index 6c0e00a..25f202a 100644 --- a/frontend/src/components/Settings/Settings.tsx +++ b/frontend/src/components/Settings/Settings.tsx @@ -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 = ( + 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(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 <> + - 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 ( - <> - setIsOpen(!isOpen)} title={t('options.name')} - > - - setIsOpen(false)} /> - - {t('options.name')} + _setIsOpen(false)} + onClick={() => modalRef.current?.close()} + > +
e.stopPropagation()}> + {t('options.name')} store.setWeekStart(value === 'Sunday' ? 0 : 1)} - inputRef={firstControlRef} + value={store?.weekStart === 0 ? 'Sunday' : 'Monday'} + onChange={value => store?.setWeekStart(value === 'Sunday' ? 0 : 1)} /> store.setTimeFormat(value)} + value={store?.timeFormat ?? '12h'} + onChange={value => store?.setTimeFormat(value)} /> store.setTheme(value)} + value={store?.theme ?? 'System'} + onChange={value => store?.setTheme(value)} /> [ @@ -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)} /> store.setHighlight(value === 'On')} + value={store?.highlight ? 'On' : 'Off'} + onChange={value => store?.setHighlight(value === 'On')} /> { 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()) + }} /> - - - ) +
+
+ } export default Settings diff --git a/frontend/src/components/TimeRangeField/TimeRangeField.tsx b/frontend/src/components/TimeRangeField/TimeRangeField.tsx index 58496d0..0e1ab4d 100644 --- a/frontend/src/components/TimeRangeField/TimeRangeField.tsx +++ b/frontend/src/components/TimeRangeField/TimeRangeField.tsx @@ -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 }) diff --git a/frontend/src/i18n/client.ts b/frontend/src/i18n/client.ts index eae3a3b..1bd0946 100644 --- a/frontend/src/i18n/client.ts +++ b/frontend/src/i18n/client.ts @@ -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) diff --git a/frontend/src/i18n/options.ts b/frontend/src/i18n/options.ts index a696e94..a930ba2 100644 --- a/frontend/src/i18n/options.ts +++ b/frontend/src/i18n/options.ts @@ -30,7 +30,7 @@ interface LanguageDetails { /** TODO: document */ separator?: string /** Day.js locale import */ - import: () => unknown + import: () => Promise } export const languageDetails: Record = { diff --git a/frontend/src/i18n/server.ts b/frontend/src/i18n/server.ts index f99d4d6..d48a8c1 100644 --- a/frontend/src/i18n/server.ts +++ b/frontend/src/i18n/server.ts @@ -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 = { -readonly [K in keyof T]: Mutable } @@ -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, } }