feat(local): set up app with language and time zone

This commit is contained in:
haitao(lj) 2022-11-04 10:35:01 +08:00
parent 549f816d3e
commit 7a8f5b67bc
18 changed files with 142 additions and 112 deletions

View File

@ -1,5 +1,6 @@
import { RouterProvider } from 'react-router-dom';
import './i18n/init';
import { routes, createBrowserRouter } from '@/router';
function App() {

View File

@ -28,7 +28,8 @@ i18next
escapeValue: false,
},
react: {
transSupportBasicHtmlNodes: true, // allow <br/> and simple html elements in translations
transSupportBasicHtmlNodes: true,
// allow <br/> and simple html elements in translations
transKeepBasicHtmlNodesFor: ['br', 'strong', 'i'],
},
// backend: {

View File

@ -2,11 +2,10 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { Guard } from '@/utils';
import { guard } from '@/utils';
import App from './App';
import './i18n/init';
import './index.scss';
const root = ReactDOM.createRoot(
@ -14,10 +13,7 @@ const root = ReactDOM.createRoot(
);
async function bootstrapApp() {
/**
* NOTICE: must pre init logged user info for router
*/
await Guard.pullLoggedUser();
await guard.setupApp();
root.render(
<React.StrictMode>
<App />

View File

@ -12,7 +12,7 @@ import { interfaceStore } from '@/stores';
import { UploadImg } from '@/components';
import { TIMEZONES, DEFAULT_TIMEZONE } from '@/common/constants';
import {
languages,
getLanguageOptions,
uploadAvatar,
updateInterfaceSetting,
useInterfaceSetting,
@ -52,7 +52,7 @@ const Interface: FC = () => {
},
});
const getLangs = async () => {
const res: LangsType[] = await languages();
const res: LangsType[] = await getLanguageOptions();
setLangs(res);
if (!formData.language.value) {
// set default theme value

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import type { LangsType, FormValue, FormDataType } from '@/common/interface';
import Progress from '../Progress';
import { languages } from '@/services';
import { getLanguageOptions } from '@/services';
interface Props {
data: FormValue;
@ -18,7 +18,7 @@ const Index: FC<Props> = ({ visible, data, changeCallback, nextCallback }) => {
const [langs, setLangs] = useState<LangsType[]>();
const getLangs = async () => {
const res: LangsType[] = await languages();
const res: LangsType[] = await getLanguageOptions();
setLangs(res);
};

View File

@ -1,42 +1,19 @@
import { FC, useEffect, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FC, memo } from 'react';
import { Outlet } from 'react-router-dom';
import { Helmet, HelmetProvider } from 'react-helmet-async';
import { SWRConfig } from 'swr';
import { siteInfoStore, interfaceStore, toastStore } from '@/stores';
import { siteInfoStore, toastStore } from '@/stores';
import { Header, AdminHeader, Footer, Toast } from '@/components';
import { useSiteSettings } from '@/services';
import Storage from '@/utils/storage';
import { CURRENT_LANG_STORAGE_KEY } from '@/common/constants';
let isMounted = false;
const Layout: FC = () => {
const { siteInfo, update: siteStoreUpdate } = siteInfoStore();
const { update: interfaceStoreUpdate } = interfaceStore();
const { data: siteSettings } = useSiteSettings();
const { msg: toastMsg, variant, clear: toastClear } = toastStore();
const { i18n } = useTranslation();
const { siteInfo } = siteInfoStore.getState();
const closeToast = () => {
toastClear();
};
useEffect(() => {
if (siteSettings) {
siteStoreUpdate(siteSettings.general);
interfaceStoreUpdate(siteSettings.interface);
}
}, [siteSettings]);
if (!isMounted) {
isMounted = true;
const lang = Storage.get(CURRENT_LANG_STORAGE_KEY);
if (lang) {
i18n.changeLanguage(lang);
}
}
return (
<HelmetProvider>
<Helmet>
@ -47,6 +24,7 @@ const Layout: FC = () => {
revalidateOnFocus: false,
}}>
<Header />
{/* TODO: move admin header to Admin/Index */}
<AdminHeader />
<div className="position-relative page-wrap">
<Outlet />

View File

@ -10,7 +10,7 @@ import type {
} from '@/common/interface';
import { PageTitle, Unactivate } from '@/components';
import { loggedUserInfoStore } from '@/stores';
import { getQueryString, Guard, floppyNavigation } from '@/utils';
import { getQueryString, guard, floppyNavigation } from '@/utils';
import { login, checkImgCode } from '@/services';
import { REDIRECT_PATH_STORAGE_KEY } from '@/common/constants';
import { RouteAlias } from '@/router/alias';
@ -104,7 +104,7 @@ const Index: React.FC = () => {
login(params)
.then((res) => {
updateUser(res);
const userStat = Guard.deriveLoginState();
const userStat = guard.deriveLoginState();
if (userStat.isNotActivated) {
// inactive
setStep(2);
@ -159,7 +159,7 @@ const Index: React.FC = () => {
if ((storeUser.id && storeUser.mail_status === 2) || isInactive) {
setStep(2);
} else {
Guard.tryNormalLogged();
guard.tryNormalLogged();
}
}, []);

View File

@ -2,49 +2,54 @@ import React, { useEffect, useState, FormEvent } from 'react';
import { Form, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import en from 'dayjs/locale/en';
import zh from 'dayjs/locale/zh-cn';
import type { LangsType, FormDataType } from '@/common/interface';
import { useToast } from '@/hooks';
import { languages } from '@/services';
import { DEFAULT_LANG, CURRENT_LANG_STORAGE_KEY } from '@/common/constants';
import { getLanguageOptions, updateUserInterface } from '@/services';
import { CURRENT_LANG_STORAGE_KEY } from '@/common/constants';
import Storage from '@/utils/storage';
import { localize } from '@/utils';
import { loggedUserInfoStore } from '@/stores';
const Index = () => {
const { t, i18n } = useTranslation('translation', {
const { t } = useTranslation('translation', {
keyPrefix: 'settings.interface',
});
const loggedUserInfo = loggedUserInfoStore.getState().user;
const toast = useToast();
const [langs, setLangs] = useState<LangsType[]>();
const [formData, setFormData] = useState<FormDataType>({
lang: {
value: true,
// FIXME: userinfo? or userInfo.language
value: loggedUserInfo,
isInvalid: false,
errorMsg: '',
},
});
const getLangs = async () => {
const res: LangsType[] = await languages();
const res: LangsType[] = await getLanguageOptions();
setLangs(res);
};
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
Storage.set(CURRENT_LANG_STORAGE_KEY, formData.lang.value);
dayjs.locale(formData.lang.value === DEFAULT_LANG ? en : zh);
i18n.changeLanguage(formData.lang.value);
toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
const lang = formData.lang.value;
updateUserInterface(lang).then(() => {
loggedUserInfoStore.getState().update({
...loggedUserInfo,
language: lang,
});
localize.setupAppLanguage();
toast.onShow({
msg: t('update', { keyPrefix: 'toast' }),
variant: 'success',
});
});
};
useEffect(() => {
getLangs();
// TODO: get default lang by interface api
const lang = Storage.get(CURRENT_LANG_STORAGE_KEY);
if (lang) {
setFormData({
@ -74,7 +79,7 @@ const Index = () => {
}}>
{langs?.map((item) => {
return (
<option value={item.value} key={item.value}>
<option value={item.value} key={item.label}>
{item.label}
</option>
);

View File

@ -1,6 +1,6 @@
import { RouteObject } from 'react-router-dom';
import { Guard } from '@/utils';
import { guard } from '@/utils';
import type { TGuardResult } from '@/utils/guard';
export interface RouteNode extends RouteObject {
@ -21,7 +21,7 @@ const routes: RouteNode[] = [
path: '/',
page: 'pages/Layout',
guard: async () => {
return Guard.notForbidden();
return guard.notForbidden();
},
children: [
// question and answer
@ -46,14 +46,14 @@ const routes: RouteNode[] = [
path: 'questions/ask',
page: 'pages/Questions/Ask',
guard: async () => {
return Guard.activated();
return guard.activated();
},
},
{
path: 'posts/:qid/edit',
page: 'pages/Questions/Ask',
guard: async () => {
return Guard.activated();
return guard.activated();
},
},
{
@ -81,7 +81,7 @@ const routes: RouteNode[] = [
path: 'tags/:tagId/edit',
page: 'pages/Tags/Edit',
guard: async () => {
return Guard.activated();
return guard.activated();
},
},
// users
@ -97,7 +97,7 @@ const routes: RouteNode[] = [
path: 'users/settings',
page: 'pages/Users/Settings',
guard: async () => {
return Guard.logged();
return guard.logged();
},
children: [
{
@ -130,25 +130,25 @@ const routes: RouteNode[] = [
path: 'users/login',
page: 'pages/Users/Login',
guard: async () => {
const notLogged = Guard.notLogged();
const notLogged = guard.notLogged();
if (notLogged.ok) {
return notLogged;
}
return Guard.notActivated();
return guard.notActivated();
},
},
{
path: 'users/register',
page: 'pages/Users/Register',
guard: async () => {
return Guard.notLogged();
return guard.notLogged();
},
},
{
path: 'users/account-recovery',
page: 'pages/Users/AccountForgot',
guard: async () => {
return Guard.activated();
return guard.activated();
},
},
{
@ -160,32 +160,32 @@ const routes: RouteNode[] = [
path: 'users/password-reset',
page: 'pages/Users/PasswordReset',
guard: async () => {
return Guard.activated();
return guard.activated();
},
},
{
path: 'users/account-activation',
page: 'pages/Users/ActiveEmail',
guard: async () => {
const notActivated = Guard.notActivated();
const notActivated = guard.notActivated();
if (notActivated.ok) {
return notActivated;
}
return Guard.notLogged();
return guard.notLogged();
},
},
{
path: 'users/account-activation/success',
page: 'pages/Users/ActivationResult',
guard: async () => {
return Guard.activated();
return guard.activated();
},
},
{
path: '/users/account-activation/failed',
page: 'pages/Users/ActivationResult',
guard: async () => {
return Guard.notActivated();
return guard.notActivated();
},
},
{
@ -197,7 +197,7 @@ const routes: RouteNode[] = [
path: '/users/account-suspended',
page: 'pages/Users/Suspended',
guard: async () => {
return Guard.forbidden();
return guard.forbidden();
},
},
// for admin
@ -205,8 +205,8 @@ const routes: RouteNode[] = [
path: 'admin',
page: 'pages/Admin',
guard: async () => {
await Guard.pullLoggedUser(true);
return Guard.admin();
await guard.pullLoggedUser(true);
return guard.admin();
},
children: [
{

View File

@ -4,3 +4,4 @@ export * from './notification';
export * from './question';
export * from './search';
export * from './tag';
export * from './settings';

View File

@ -0,0 +1,18 @@
// import useSWR from 'swr';
import request from '@/utils/request';
import type * as Type from '@/common/interface';
export const loadLang = () => {
return request.get('/answer/api/v1/language/config');
};
export const getLanguageOptions = () => {
return request.get<Type.LangsType[]>('/answer/api/v1/language/options');
};
export const updateUserInterface = (lang: string) => {
return request.put('/answer/api/v1/user/interface', {
language: lang,
});
};

View File

@ -163,14 +163,6 @@ export const questionDetail = (id: string) => {
);
};
export const langConfig = () => {
return request.get('/answer/api/v1/language/config');
};
export const languages = () => {
return request.get<Type.LangsType[]>('/answer/api/v1/language/options');
};
export const getAnswers = (params: Type.AnswersReq) => {
const apiUrl = `/answer/api/v1/answer/page?${qs.stringify(params)}`;
return request.get<Type.ListResult<Type.AnswerItem>>(apiUrl);
@ -253,16 +245,6 @@ export const changeEmailVerify = (params: { code: string }) => {
return request.put('/answer/api/v1/user/email', params);
};
export const useSiteSettings = () => {
const apiUrl = `/answer/api/v1/siteinfo`;
const { data, error } = useSWR<Type.SiteSettings, Error>(
[apiUrl],
request.instance.get,
);
return {
data,
isLoading: !data && !error,
error,
};
export const getAppSettings = () => {
return request.get<Type.SiteSettings>('/answer/api/v1/siteinfo');
};

View File

@ -1,14 +1,10 @@
import create from 'zustand';
interface updateParams {
logo: string;
theme: string;
language: string;
}
import { AdminSettingsInterface } from '@/common/interface';
interface InterfaceType {
interface: updateParams;
update: (params: updateParams) => void;
interface: AdminSettingsInterface;
update: (params: AdminSettingsInterface) => void;
}
const interfaceSetting = create<InterfaceType>((set) => ({
@ -16,6 +12,7 @@ const interfaceSetting = create<InterfaceType>((set) => ({
logo: '',
theme: '',
language: '',
time_zone: '',
},
update: (params) =>
set(() => {

View File

@ -1,14 +1,10 @@
import create from 'zustand';
interface updateParams {
name: string;
description: string;
short_description: string;
}
import { AdminSettingsGeneral } from '@/common/interface';
interface SiteInfoType {
siteInfo: updateParams;
update: (params: updateParams) => void;
siteInfo: AdminSettingsGeneral;
update: (params: AdminSettingsGeneral) => void;
}
const siteInfo = create<SiteInfoType>((set) => ({

View File

@ -25,6 +25,7 @@ const initUser: UserInfoRes = {
website: '',
status: '',
mail_status: 1,
language: '',
};
const loggedUserInfoStore = create<UserInfoStore>((set) => ({

View File

@ -1,8 +1,9 @@
import { getLoggedUserInfo } from '@/services';
import { loggedUserInfoStore } from '@/stores';
import { getLoggedUserInfo, getAppSettings } from '@/services';
import { loggedUserInfoStore, siteInfoStore, interfaceStore } from '@/stores';
import { RouteAlias } from '@/router/alias';
import Storage from '@/utils/storage';
import { LOGGED_USER_STORAGE_KEY } from '@/common/constants';
import { setupAppLanguage, setupAppTimeZone } from '@/utils/localize';
import { floppyNavigation } from './floppyNavigation';
@ -180,3 +181,23 @@ export const tryNormalLogged = (autoLogin: boolean = false) => {
return false;
};
export const initAppSettingsStore = async () => {
const appSettings = await getAppSettings();
if (appSettings) {
siteInfoStore.getState().update(appSettings.general);
interfaceStore.getState().update(appSettings.interface);
}
};
export const setupApp = async () => {
/**
* WARN:
* 1. must pre init logged user info for router guard
* 2. must pre init app settings for app render
*/
// TODO: optimize `initAppSettingsStore` by server render
await Promise.allSettled([pullLoggedUser(), initAppSettingsStore()]);
setupAppLanguage();
setupAppTimeZone();
};

View File

@ -2,5 +2,6 @@ export { default as request } from './request';
export { default as Storage } from './storage';
export { floppyNavigation } from './floppyNavigation';
export * as Guard from './guard';
export * as guard from './guard';
export * as localize from './localize';
export * from './common';

32
ui/src/utils/localize.ts Normal file
View File

@ -0,0 +1,32 @@
import dayjs from 'dayjs';
import i18next from 'i18next';
import { interfaceStore, loggedUserInfoStore } from '@/stores';
import { DEFAULT_LANG } from '@/common/constants';
const localDayjs = (langName) => {
langName = langName.replace('_', '-').toLowerCase();
dayjs.locale(langName);
};
export const getCurrentLang = () => {
const loggedUser = loggedUserInfoStore.getState().user;
const adminInterface = interfaceStore.getState().interface;
let currentLang = loggedUser.language;
// `default` mean use language value from admin interface
if (/default/i.test(currentLang) && adminInterface.language) {
currentLang = adminInterface.language;
}
currentLang ||= DEFAULT_LANG;
return currentLang;
};
export const setupAppLanguage = () => {
const lang = getCurrentLang();
localDayjs(lang);
i18next.changeLanguage(lang);
};
export const setupAppTimeZone = () => {
// FIXME
};