feat: customize system profile (#828)

This commit is contained in:
boojack 2022-12-22 19:48:44 +08:00 committed by GitHub
parent 72daa4e1d6
commit 7efa749c66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 171 additions and 141 deletions

View File

@ -3,6 +3,8 @@ package api
import (
"encoding/json"
"fmt"
"golang.org/x/exp/slices"
)
type SystemSettingName string
@ -24,6 +26,12 @@ type CustomizedProfile struct {
Name string `json:"name"`
// IconURL is the url of icon image.
IconURL string `json:"iconUrl"`
// Description is the server description.
Description string `json:"description"`
// Locale is the server default locale.
Locale string `json:"locale"`
// Appearance is the server default appearance.
Appearance string `json:"appearance"`
// ExternalURL is the external url of server. e.g. https://usermemos.com
ExternalURL string `json:"externalUrl"`
}
@ -90,15 +98,24 @@ func (upsert SystemSettingUpsert) Validate() error {
return fmt.Errorf("failed to unmarshal system setting additional script value")
}
} else if upsert.Name == SystemSettingCustomizedProfileName {
value := CustomizedProfile{
customizedProfile := CustomizedProfile{
Name: "memos",
IconURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
}
err := json.Unmarshal([]byte(upsert.Value), &value)
err := json.Unmarshal([]byte(upsert.Value), &customizedProfile)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting customized profile value")
}
if !slices.Contains(UserSettingLocaleValue, customizedProfile.Locale) {
return fmt.Errorf("invalid locale value")
}
if !slices.Contains(UserSettingAppearanceValue, customizedProfile.Appearance) {
return fmt.Errorf("invalid appearance value")
}
} else {
return fmt.Errorf("invalid system setting name")
}

View File

@ -3,6 +3,8 @@ package api
import (
"encoding/json"
"fmt"
"golang.org/x/exp/slices"
)
type UserSettingKey string
@ -60,32 +62,16 @@ func (upsert UserSettingUpsert) Validate() error {
if err != nil {
return fmt.Errorf("failed to unmarshal user setting locale value")
}
invalid := true
for _, value := range UserSettingLocaleValue {
if localeValue == value {
invalid = false
break
}
}
if invalid {
if !slices.Contains(UserSettingLocaleValue, localeValue) {
return fmt.Errorf("invalid user setting locale value")
}
} else if upsert.Key == UserSettingAppearanceKey {
appearanceValue := "light"
appearanceValue := "system"
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting appearance value")
}
invalid := true
for _, value := range UserSettingAppearanceValue {
if appearanceValue == value {
invalid = false
break
}
}
if invalid {
if !slices.Contains(UserSettingAppearanceValue, appearanceValue) {
return fmt.Errorf("invalid user setting appearance value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey {
@ -94,15 +80,7 @@ func (upsert UserSettingUpsert) Validate() error {
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo visibility value")
}
invalid := true
for _, value := range UserSettingMemoVisibilityValue {
if memoVisibilityValue == value {
invalid = false
break
}
}
if invalid {
if !slices.Contains(UserSettingMemoVisibilityValue, memoVisibilityValue) {
return fmt.Errorf("invalid user setting memo visibility value")
}
} else if upsert.Key == UserSettingMemoDisplayTsOptionKey {
@ -111,15 +89,7 @@ func (upsert UserSettingUpsert) Validate() error {
if err != nil {
return fmt.Errorf("failed to unmarshal user setting memo display ts option")
}
invalid := true
for _, value := range UserSettingMemoDisplayTsOptionKeyValue {
if memoDisplayTsOption == value {
invalid = false
break
}
}
if invalid {
if !slices.Contains(UserSettingMemoDisplayTsOptionKeyValue, memoDisplayTsOption) {
return fmt.Errorf("invalid user setting memo display ts option value")
}
} else {

7
go.mod
View File

@ -38,11 +38,14 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.1 // indirect
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require github.com/segmentio/analytics-go v3.1.0+incompatible
require (
github.com/segmentio/analytics-go v3.1.0+incompatible
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15
)

6
go.sum
View File

@ -70,14 +70,16 @@ github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEAB
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 h1:5oN1Pz/eDhCpbMbLstvIPa0b/BEQo6g6nwV3pLjfM6w=
golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201 h1:bvOltf3SADAfG05iRml8lAB3qjoEX5RCyN4K6G5v3N0=
golang.org/x/net v0.0.0-20220728030405-41545e8bf201/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ=

View File

@ -27,6 +27,7 @@ func setUserSession(ctx echo.Context, user *api.User) error {
Path: "/",
MaxAge: 3600 * 24 * 30,
HttpOnly: true,
Secure: true,
}
sess.Values[userIDContextKey] = user.ID
err := sess.Save(ctx.Request(), ctx.Response())

View File

@ -47,7 +47,12 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
AdditionalStyle: "",
AdditionalScript: "",
CustomizedProfile: api.CustomizedProfile{
Name: "memos",
Name: "memos",
IconURL: "",
Description: "",
Locale: "en",
Appearance: "system",
ExternalURL: "",
},
}
@ -73,6 +78,9 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
systemStatus.CustomizedProfile = api.CustomizedProfile{
Name: valueMap["name"].(string),
IconURL: valueMap["iconUrl"].(string),
Description: valueMap["description"].(string),
Locale: valueMap["locale"].(string),
Appearance: valueMap["appearance"].(string),
ExternalURL: valueMap["externalUrl"].(string),
}
}

View File

@ -1,16 +1,19 @@
import { Option, Select } from "@mui/joy";
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore, useUserStore } from "../store/module";
import Icon from "./Icon";
interface Props {
value: Appearance;
onChange: (appearance: Appearance) => void;
className?: string;
}
const appearanceList = ["system", "light", "dark"];
const AppearanceSelect = () => {
const AppearanceSelect: FC<Props> = (props: Props) => {
const { onChange, value, className } = props;
const { t } = useTranslation();
const globalStore = useGlobalStore();
const userStore = useUserStore();
const { appearance } = globalStore.state;
const user = userStore.state.user;
const getPrefixIcon = (apperance: Appearance) => {
const className = "w-4 h-auto";
@ -24,22 +27,19 @@ const AppearanceSelect = () => {
};
const handleSelectChange = async (appearance: Appearance) => {
if (user) {
await userStore.upsertUserSetting("appearance", appearance);
}
globalStore.setAppearance(appearance);
onChange(appearance);
};
return (
<Select
className="!min-w-[10rem] w-auto text-sm"
value={appearance}
className={`!min-w-[10rem] w-auto whitespace-nowrap ${className ?? ""}`}
value={value}
onChange={(_, appearance) => {
if (appearance) {
handleSelectChange(appearance);
}
}}
startDecorator={getPrefixIcon(appearance)}
startDecorator={getPrefixIcon(value)}
>
{appearanceList.map((item) => (
<Option key={item} value={item} className="whitespace-nowrap">

View File

@ -0,0 +1,37 @@
import { Option, Select } from "@mui/joy";
import { FC } from "react";
import Icon from "./Icon";
interface Props {
value: Locale;
onChange: (locale: Locale) => void;
className?: string;
}
const LocaleSelect: FC<Props> = (props: Props) => {
const { onChange, value, className } = props;
const handleSelectChange = async (locale: Locale) => {
onChange(locale);
};
return (
<Select
className={`!min-w-[10rem] w-auto whitespace-nowrap ${className ?? ""}`}
startDecorator={<Icon.Globe className="w-4 h-auto" />}
value={value}
onChange={(_, value) => handleSelectChange(value as Locale)}
>
<Option value="en">English</Option>
<Option value="zh"></Option>
<Option value="vi">Tiếng Việt</Option>
<Option value="fr">French</Option>
<Option value="nl">Nederlands</Option>
<Option value="sv">Svenska</Option>
<Option value="de">German</Option>
<Option value="es">Español</Option>
</Select>
);
};
export default LocaleSelect;

View File

@ -2,49 +2,15 @@ import { Select, Switch, Option } from "@mui/joy";
import { useTranslation } from "react-i18next";
import { useGlobalStore, useUserStore } from "../../store/module";
import { VISIBILITY_SELECTOR_ITEMS, MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS } from "../../helpers/consts";
import Icon from "../Icon";
import AppearanceSelect from "../AppearanceSelect";
import LocaleSelect from "../LocaleSelect";
import "../../less/settings/preferences-section.less";
const localeSelectorItems = [
{
text: "English",
value: "en",
},
{
text: "中文",
value: "zh",
},
{
text: "Tiếng Việt",
value: "vi",
},
{
text: "French",
value: "fr",
},
{
text: "Nederlands",
value: "nl",
},
{
text: "Svenska",
value: "sv",
},
{
text: "German",
value: "de",
},
{
text: "Español",
value: "es",
},
];
const PreferencesSection = () => {
const { t } = useTranslation();
const globalStore = useGlobalStore();
const userStore = useUserStore();
const { appearance, locale } = globalStore.state;
const { setting, localSetting } = userStore.state.user as User;
const visibilitySelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
@ -60,9 +26,14 @@ const PreferencesSection = () => {
};
});
const handleLocaleChanged = async (value: string) => {
await userStore.upsertUserSetting("locale", value);
globalStore.setLocale(value as Locale);
const handleLocaleSelectChange = async (locale: Locale) => {
await userStore.upsertUserSetting("locale", locale);
globalStore.setLocale(locale);
};
const handleAppearanceSelectChange = async (appearance: Appearance) => {
await userStore.upsertUserSetting("appearance", appearance);
globalStore.setAppearance(appearance);
};
const handleDefaultMemoVisibilityChanged = async (value: string) => {
@ -82,26 +53,11 @@ const PreferencesSection = () => {
<p className="title-text">{t("common.basic")}</p>
<div className="form-label selector">
<span className="normal-text">{t("common.language")}</span>
<Select
className="!min-w-[10rem] w-auto text-sm"
value={setting.locale}
onChange={(_, locale) => {
if (locale) {
handleLocaleChanged(locale);
}
}}
startDecorator={<Icon.Globe className="w-4 h-auto" />}
>
{localeSelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
{item.text}
</Option>
))}
</Select>
<LocaleSelect value={locale} onChange={handleLocaleSelectChange} />
</div>
<div className="form-label selector">
<span className="normal-text">{t("setting.preference-section.theme")}</span>
<AppearanceSelect />
<AppearanceSelect value={appearance} onChange={handleAppearanceSelectChange} />
</div>
<p className="title-text">{t("setting.preference")}</p>
<div className="form-label selector">

View File

@ -5,6 +5,8 @@ import * as api from "../helpers/api";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import toastHelper from "./Toast";
import LocaleSelect from "./LocaleSelect";
import AppearanceSelect from "./AppearanceSelect";
type Props = DialogProps;
@ -39,6 +41,33 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
});
};
const handleDescriptionChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((state) => {
return {
...state,
description: e.target.value as string,
};
});
};
const handleLocaleSelectChange = (locale: Locale) => {
setState((state) => {
return {
...state,
locale: locale,
};
});
};
const handleAppearanceSelectChange = (appearance: Appearance) => {
setState((state) => {
return {
...state,
appearance: appearance,
};
});
};
const handleSaveBtnClick = async () => {
if (state.name === "" || state.iconUrl === "") {
toastHelper.error(t("message.fill-all"));
@ -61,13 +90,13 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
return (
<>
<div className="dialog-header-container !w-64">
<div className="dialog-header-container">
<p className="title-text">{t("setting.system-section.customize-server.title")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<div className="dialog-content-container !w-80">
<p className="text-sm mb-1">
{t("setting.system-section.server-name")}
<span className="text-sm text-gray-400 ml-1">({t("setting.system-section.customize-server.default")})</span>
@ -75,6 +104,12 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
<input type="text" className="input-text" value={state.name} onChange={handleNameChanged} />
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.icon-url")}</p>
<input type="text" className="input-text" value={state.iconUrl} onChange={handleIconUrlChanged} />
<p className="text-sm mb-1 mt-2">Description</p>
<input type="text" className="input-text" value={state.description} onChange={handleDescriptionChanged} />
<p className="text-sm mb-1 mt-2">Server locale</p>
<LocaleSelect className="w-full" value={state.locale} onChange={handleLocaleSelectChange} />
<p className="text-sm mb-1 mt-2">Server appearance</p>
<AppearanceSelect className="w-full" value={state.appearance} onChange={handleAppearanceSelectChange} />
<div className="mt-4 w-full flex flex-row justify-end items-center space-x-2">
<span className="btn-text" onClick={handleCloseBtnClick}>
{t("common.cancel")}

View File

@ -1,4 +1,3 @@
import { Option, Select } from "@mui/joy";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@ -9,6 +8,7 @@ import useLoading from "../hooks/useLoading";
import Icon from "../components/Icon";
import toastHelper from "../components/Toast";
import AppearanceSelect from "../components/AppearanceSelect";
import LocaleSelect from "../components/LocaleSelect";
import "../less/auth.less";
const validateConfig: ValidatorConfig = {
@ -19,12 +19,12 @@ const validateConfig: ValidatorConfig = {
};
const Auth = () => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const navigate = useNavigate();
const globalStore = useGlobalStore();
const userStore = useUserStore();
const actionBtnLoadingState = useLoading(false);
const systemStatus = globalStore.state.systemStatus;
const { appearance, locale, systemStatus } = globalStore.state;
const mode = systemStatus.profile.mode;
const [username, setUsername] = useState(mode === "dev" ? "demohero" : "");
const [password, setPassword] = useState(mode === "dev" ? "secret" : "");
@ -43,6 +43,14 @@ const Auth = () => {
setPassword(text);
};
const handleLocaleSelectChange = (locale: Locale) => {
globalStore.setLocale(locale);
};
const handleAppearanceSelectChange = (appearance: Appearance) => {
globalStore.setAppearance(appearance);
};
const handleSigninBtnsClick = async () => {
if (actionBtnLoadingState.isLoading) {
return;
@ -109,10 +117,6 @@ const Auth = () => {
actionBtnLoadingState.setFinish();
};
const handleLocaleItemClick = (locale: Locale) => {
globalStore.setLocale(locale);
};
return (
<div className="page-wrapper auth">
<div className="page-container">
@ -122,7 +126,7 @@ const Auth = () => {
<img className="logo-img" src={systemStatus.customizedProfile.iconUrl} alt="" />
<p className="logo-text">{systemStatus.customizedProfile.name}</p>
</div>
<p className="slogan-text">{t("slogan")}</p>
<p className="slogan-text">{systemStatus.customizedProfile.description || t("slogan")}</p>
</div>
<div className={`page-content-container ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}>
<div className="form-item-container input-form-container">
@ -167,22 +171,8 @@ const Auth = () => {
{!systemStatus?.host && <p className="tip-text">{t("auth.host-tip")}</p>}
</div>
<div className="flex flex-row items-center justify-center w-full gap-2">
<Select
className="!min-w-[9rem] w-auto whitespace-nowrap"
startDecorator={<Icon.Globe className="w-4 h-auto" />}
value={i18n.language}
onChange={(_, value) => handleLocaleItemClick(value as Locale)}
>
<Option value="en">English</Option>
<Option value="zh"></Option>
<Option value="vi">Tiếng Việt</Option>
<Option value="fr">French</Option>
<Option value="nl">Nederlands</Option>
<Option value="sv">Svenska</Option>
<Option value="de">German</Option>
<Option value="es">Español</Option>
</Select>
<AppearanceSelect />
<LocaleSelect value={locale} onChange={handleLocaleSelectChange} />
<AppearanceSelect value={appearance} onChange={handleAppearanceSelectChange} />
</div>
</div>
</div>

View File

@ -13,7 +13,10 @@ export const initialGlobalState = async () => {
additionalScript: "",
customizedProfile: {
name: "memos",
iconUrl: "/logo.webp",
iconUrl: "https://usememos.com/logo.webp",
description: "",
locale: "en",
appearance: "system",
externalUrl: "",
},
} as SystemStatus,
@ -31,6 +34,8 @@ export const initialGlobalState = async () => {
const { data } = (await api.getSystemStatus()).data;
if (data) {
defaultGlobalState.systemStatus = data;
defaultGlobalState.locale = data.customizedProfile.locale;
defaultGlobalState.appearance = data.customizedProfile.appearance;
}
} catch (error) {
// do nth

View File

@ -23,7 +23,10 @@ const globalSlice = createSlice({
additionalScript: "",
customizedProfile: {
name: "memos",
iconUrl: "/logo.webp",
iconUrl: "https://usememos.com/logo.webp",
description: "",
locale: "en",
appearance: "system",
externalUrl: "",
},
},

View File

@ -6,6 +6,9 @@ interface Profile {
interface CustomizedProfile {
name: string;
iconUrl: string;
description: string;
locale: Locale;
appearance: Appearance;
externalUrl: string;
}