From 90b881502dee3c1fae1f8fd34abc8733813d265c Mon Sep 17 00:00:00 2001 From: boojack Date: Sat, 13 Aug 2022 14:35:33 +0800 Subject: [PATCH] feat: add `user_setting` model (#145) * feat: add `user_setting` model * chore: add global store * chore: update settings in web * chore: update `i18n` example --- api/user.go | 11 +- api/user_setting.go | 34 +++++ .../docker-compose.yaml | 2 +- server/user.go | 69 +++++++--- store/db/migration/dev/LATEST__SCHEMA.sql | 11 ++ .../migration/prod/0.4/00__user_setting.sql | 9 ++ store/user_setting.go | 122 ++++++++++++++++++ web/src/App.tsx | 17 ++- web/src/components/AboutSiteDialog.tsx | 8 +- .../Settings/PreferencesSection.tsx | 48 +++++-- web/src/components/common/Selector.tsx | 6 +- web/src/helpers/api.ts | 4 + web/src/helpers/storage.ts | 5 +- web/src/labs/i18n/useI18n.ts | 15 ++- web/src/locales/en.json | 7 +- web/src/locales/zh.json | 7 +- web/src/pages/Signin.tsx | 8 +- web/src/services/globalService.ts | 41 ++++++ web/src/services/index.ts | 3 +- web/src/services/userService.ts | 25 +++- web/src/store/index.ts | 2 + web/src/store/modules/global.ts | 25 ++++ web/src/types/i18n.d.ts | 1 + web/src/types/modules/setting.d.ts | 15 +++ web/src/types/modules/user.d.ts | 3 + 25 files changed, 442 insertions(+), 56 deletions(-) create mode 100644 api/user_setting.go rename docker-compose.yaml => quickstart/docker-compose.yaml (82%) create mode 100644 store/db/migration/prod/0.4/00__user_setting.sql create mode 100644 store/user_setting.go create mode 100644 web/src/services/globalService.ts create mode 100644 web/src/store/modules/global.ts create mode 100644 web/src/types/i18n.d.ts create mode 100644 web/src/types/modules/setting.d.ts diff --git a/api/user.go b/api/user.go index 136256234..5d1634d6c 100644 --- a/api/user.go +++ b/api/user.go @@ -29,11 +29,12 @@ type User struct { UpdatedTs int64 `json:"updatedTs"` // Domain specific fields - Email string `json:"email"` - Role Role `json:"role"` - Name string `json:"name"` - PasswordHash string `json:"-"` - OpenID string `json:"openId"` + Email string `json:"email"` + Role Role `json:"role"` + Name string `json:"name"` + PasswordHash string `json:"-"` + OpenID string `json:"openId"` + UserSettingList []*UserSetting `json:"userSettingList"` } type UserCreate struct { diff --git a/api/user_setting.go b/api/user_setting.go new file mode 100644 index 000000000..ff5b0bb7d --- /dev/null +++ b/api/user_setting.go @@ -0,0 +1,34 @@ +package api + +type UserSettingKey string + +const ( + // UserSettingLocaleKey is the key type for user locale + UserSettingLocaleKey UserSettingKey = "locale" +) + +// String returns the string format of UserSettingKey type. +func (key UserSettingKey) String() string { + switch key { + case UserSettingLocaleKey: + return "locale" + } + return "" +} + +type UserSetting struct { + UserID int + Key UserSettingKey `json:"key"` + // Value is a JSON string with basic value + Value string `json:"value"` +} + +type UserSettingUpsert struct { + UserID int + Key UserSettingKey `json:"key"` + Value string `json:"value"` +} + +type UserSettingFind struct { + UserID int +} diff --git a/docker-compose.yaml b/quickstart/docker-compose.yaml similarity index 82% rename from docker-compose.yaml rename to quickstart/docker-compose.yaml index c880a71f8..3360f1e8c 100644 --- a/docker-compose.yaml +++ b/quickstart/docker-compose.yaml @@ -7,4 +7,4 @@ services: - "5230:5230" volumes: - ~/.memos/:/var/opt/memos - command: --mode=prod --port=5230 \ No newline at end of file + command: --mode=prod --port=5230 diff --git a/server/user.go b/server/user.go index e6dfb63b3..8210e91e9 100644 --- a/server/user.go +++ b/server/user.go @@ -58,24 +58,29 @@ func (s *Server) registerUserRoutes(g *echo.Group) { return nil }) - g.GET("/user/:id", func(c echo.Context) error { + // GET /api/user/me is used to check if the user is logged in. + g.GET("/user/me", func(c echo.Context) error { ctx := c.Request().Context() - id, err := strconv.Atoi(c.Param("id")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err) + userID, ok := c.Get(getUserIDContextKey()).(int) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") } - user, err := s.Store.FindUser(ctx, &api.UserFind{ - ID: &id, - }) + userFind := &api.UserFind{ + ID: &userID, + } + user, err := s.Store.FindUser(ctx, userFind) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) } - if user != nil { - // data desensitize - user.OpenID = "" + userSettingList, err := s.Store.FindUserSettingList(ctx, &api.UserSettingFind{ + UserID: userID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find userSettingList").SetInternal(err) } + user.UserSettingList = userSettingList c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil { @@ -84,22 +89,54 @@ func (s *Server) registerUserRoutes(g *echo.Group) { return nil }) - // GET /api/user/me is used to check if the user is logged in. - g.GET("/user/me", func(c echo.Context) error { + g.POST("/user/setting", func(c echo.Context) error { ctx := c.Request().Context() userID, ok := c.Get(getUserIDContextKey()).(int) if !ok { return echo.NewHTTPError(http.StatusUnauthorized, "Missing auth session") } - userFind := &api.UserFind{ - ID: &userID, + userSettingUpsert := &api.UserSettingUpsert{} + if err := json.NewDecoder(c.Request().Body).Decode(userSettingUpsert); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post user setting upsert request").SetInternal(err) } - user, err := s.Store.FindUser(ctx, userFind) + + if userSettingUpsert.Key.String() == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user setting key") + } + + userSettingUpsert.UserID = userID + userSetting, err := s.Store.UpsertUserSetting(ctx, userSettingUpsert) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert user setting").SetInternal(err) + } + + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) + if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(userSetting)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user setting response").SetInternal(err) + } + return nil + }) + + g.GET("/user/:id", func(c echo.Context) error { + ctx := c.Request().Context() + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Malformatted user id").SetInternal(err) + } + + user, err := s.Store.FindUser(ctx, &api.UserFind{ + ID: &id, + }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err) } + if user != nil { + // data desensitize + user.OpenID = "" + } + c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8) if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(user)); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err) diff --git a/store/db/migration/dev/LATEST__SCHEMA.sql b/store/db/migration/dev/LATEST__SCHEMA.sql index ca0bb1480..5c1bc47e9 100644 --- a/store/db/migration/dev/LATEST__SCHEMA.sql +++ b/store/db/migration/dev/LATEST__SCHEMA.sql @@ -3,6 +3,7 @@ DROP TABLE IF EXISTS `memo_organizer`; DROP TABLE IF EXISTS `memo`; DROP TABLE IF EXISTS `shortcut`; DROP TABLE IF EXISTS `resource`; +DROP TABLE IF EXISTS `user_setting`; DROP TABLE IF EXISTS `user`; -- user @@ -139,3 +140,13 @@ SET WHERE rowid = old.rowid; END; + +-- user_setting +CREATE TABLE user_setting ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id); diff --git a/store/db/migration/prod/0.4/00__user_setting.sql b/store/db/migration/prod/0.4/00__user_setting.sql new file mode 100644 index 000000000..be65387f3 --- /dev/null +++ b/store/db/migration/prod/0.4/00__user_setting.sql @@ -0,0 +1,9 @@ +-- user_setting +CREATE TABLE user_setting ( + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX user_setting_key_user_id_index ON user_setting(key, user_id); diff --git a/store/user_setting.go b/store/user_setting.go new file mode 100644 index 000000000..2f32a1f1a --- /dev/null +++ b/store/user_setting.go @@ -0,0 +1,122 @@ +package store + +import ( + "context" + "database/sql" + + "github.com/usememos/memos/api" +) + +type userSettingRaw struct { + UserID int + Key api.UserSettingKey + Value string +} + +func (raw *userSettingRaw) toUserSetting() *api.UserSetting { + return &api.UserSetting{ + UserID: raw.UserID, + Key: raw.Key, + Value: raw.Value, + } +} + +func (s *Store) UpsertUserSetting(ctx context.Context, upsert *api.UserSettingUpsert) (*api.UserSetting, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, FormatError(err) + } + defer tx.Rollback() + + userSettingRaw, err := upsertUserSetting(ctx, tx, upsert) + if err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + userSetting := userSettingRaw.toUserSetting() + + return userSetting, nil +} + +func (s *Store) FindUserSettingList(ctx context.Context, find *api.UserSettingFind) ([]*api.UserSetting, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, FormatError(err) + } + defer tx.Rollback() + + userSettingRawList, err := findUserSettingList(ctx, tx, find) + if err != nil { + return nil, err + } + + list := []*api.UserSetting{} + for _, raw := range userSettingRawList { + list = append(list, raw.toUserSetting()) + } + + return list, nil +} + +func upsertUserSetting(ctx context.Context, tx *sql.Tx, upsert *api.UserSettingUpsert) (*userSettingRaw, error) { + query := ` + INSERT INTO user_setting ( + user_id, key, value + ) + VALUES (?, ?, ?) + ON CONFLICT(user_id, key) DO UPDATE + SET + value = EXCLUDED.value + RETURNING user_id, key, value + ` + var userSettingRaw userSettingRaw + if err := tx.QueryRowContext(ctx, query, upsert.UserID, upsert.Key, upsert.Value).Scan( + &userSettingRaw.UserID, + &userSettingRaw.Key, + &userSettingRaw.Value, + ); err != nil { + return nil, FormatError(err) + } + + return &userSettingRaw, nil +} + +func findUserSettingList(ctx context.Context, tx *sql.Tx, find *api.UserSettingFind) ([]*userSettingRaw, error) { + query := ` + SELECT + user_id, + key, + value + FROM user_setting + WHERE user_id = ? + ` + rows, err := tx.QueryContext(ctx, query, find.UserID) + if err != nil { + return nil, FormatError(err) + } + defer rows.Close() + + userSettingRawList := make([]*userSettingRaw, 0) + for rows.Next() { + var userSettingRaw userSettingRaw + if err := rows.Scan( + &userSettingRaw.UserID, + &userSettingRaw.Key, + &userSettingRaw.Value, + ); err != nil { + return nil, FormatError(err) + } + + userSettingRawList = append(userSettingRawList, &userSettingRaw) + } + + if err := rows.Err(); err != nil { + return nil, FormatError(err) + } + + return userSettingRawList, nil +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 19bd2a616..e67a278a7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,13 @@ import { useEffect, useState } from "react"; +import useI18n from "./hooks/useI18n"; import { appRouterSwitch } from "./routers"; -import { locationService } from "./services"; +import { globalService, locationService } from "./services"; import { useAppSelector } from "./store"; +import * as storage from "./helpers/storage"; function App() { + const { setLocale } = useI18n(); + const global = useAppSelector((state) => state.global); const pathname = useAppSelector((state) => state.location.pathname); const [isLoading, setLoading] = useState(true); @@ -12,9 +16,18 @@ function App() { window.onpopstate = () => { locationService.updateStateWithLocation(); }; - setLoading(false); + globalService.initialState().then(() => { + setLoading(false); + }); }, []); + useEffect(() => { + setLocale(global.locale); + storage.set({ + locale: global.locale, + }); + }, [global]); + return <>{isLoading ? null : appRouterSwitch(pathname)}; } diff --git a/web/src/components/AboutSiteDialog.tsx b/web/src/components/AboutSiteDialog.tsx index fa4ed6ed9..869baa0b7 100644 --- a/web/src/components/AboutSiteDialog.tsx +++ b/web/src/components/AboutSiteDialog.tsx @@ -10,8 +10,8 @@ import "../less/about-site-dialog.less"; interface Props extends DialogProps {} const AboutSiteDialog: React.FC = ({ destroy }: Props) => { + const { t } = useI18n(); const [profile, setProfile] = useState(); - const { t, setLocale } = useI18n(); useEffect(() => { try { @@ -27,10 +27,6 @@ const AboutSiteDialog: React.FC = ({ destroy }: Props) => { version: "0.0.0", }); } - - setTimeout(() => { - setLocale("zh"); - }, 2333); }, []); const handleCloseBtnClick = () => { @@ -42,7 +38,7 @@ const AboutSiteDialog: React.FC = ({ destroy }: Props) => {

🤠 - {t("about")} Memos + {t("common.about")} Memos

- -
+ {/* Hide export/import buttons */} + + +

Others

+
+ + +
+
); }; diff --git a/web/src/components/common/Selector.tsx b/web/src/components/common/Selector.tsx index d1bf80bef..4224845d8 100644 --- a/web/src/components/common/Selector.tsx +++ b/web/src/components/common/Selector.tsx @@ -3,7 +3,7 @@ import useToggle from "../../hooks/useToggle"; import Icon from "../Icon"; import "../../less/common/selector.less"; -interface TVObject { +interface SelectorItem { text: string; value: string; } @@ -11,7 +11,7 @@ interface TVObject { interface Props { className?: string; value: string; - dataSource: TVObject[]; + dataSource: SelectorItem[]; handleValueChanged?: (value: string) => void; } @@ -48,7 +48,7 @@ const Selector: React.FC = (props: Props) => { } }, [showSelector]); - const handleItemClick = (item: TVObject) => { + const handleItemClick = (item: SelectorItem) => { if (handleValueChanged) { handleValueChanged(item.value); } diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 5e47b9c86..886903701 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -46,6 +46,10 @@ export function getUserById(id: number) { return axios.get>(`/api/user/${id}`); } +export function upsertUserSetting(upsert: UserSettingUpsert) { + return axios.post>(`/api/user/setting`, upsert); +} + export function patchUser(userPatch: UserPatch) { return axios.patch>(`/api/user/${userPatch.id}`, userPatch); } diff --git a/web/src/helpers/storage.ts b/web/src/helpers/storage.ts index 391539aa6..e767ecfe8 100644 --- a/web/src/helpers/storage.ts +++ b/web/src/helpers/storage.ts @@ -4,9 +4,8 @@ interface StorageData { // Editor content cache editorContentCache: string; - shouldSplitMemoWord: boolean; - shouldHideImageUrl: boolean; - shouldUseMarkdownParser: boolean; + // locale + locale: Locale; } type StorageKey = keyof StorageData; diff --git a/web/src/labs/i18n/useI18n.ts b/web/src/labs/i18n/useI18n.ts index 0334b578d..b005dd347 100644 --- a/web/src/labs/i18n/useI18n.ts +++ b/web/src/labs/i18n/useI18n.ts @@ -24,10 +24,19 @@ const useI18n = () => { }, []); const translate = (key: string) => { - try { - const value = resources[locale][key] as string; + const keys = key.split("."); + let value = resources[locale]; + for (const k of keys) { + if (value) { + value = value[k]; + } else { + return key; + } + } + + if (value) { return value; - } catch (error) { + } else { return key; } }; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 287c80f45..7c9be418a 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -1,3 +1,8 @@ { - "about": "About" + "common": { + "about": "About", + "email": "Email", + "password": "Password", + "sign-in": "Sign in" + } } diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 283465b6e..92259f0b8 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -1,3 +1,8 @@ { - "about": "关于" + "common": { + "about": "关于", + "email": "邮箱", + "password": "密码", + "sign-in": "登录" + } } diff --git a/web/src/pages/Signin.tsx b/web/src/pages/Signin.tsx index 70399a29a..6395e80c2 100644 --- a/web/src/pages/Signin.tsx +++ b/web/src/pages/Signin.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import * as api from "../helpers/api"; import { validate, ValidatorConfig } from "../helpers/validator"; +import useI18n from "../hooks/useI18n"; import useLoading from "../hooks/useLoading"; import { locationService, userService } from "../services"; import toastHelper from "../components/Toast"; @@ -17,6 +18,7 @@ const validateConfig: ValidatorConfig = { }; const Signin: React.FC = () => { + const { t } = useI18n(); const pageLoadingState = useLoading(true); const [siteHost, setSiteHost] = useState(); const [email, setEmail] = useState(""); @@ -127,11 +129,11 @@ const Signin: React.FC = () => {
- Email + {t("common.email")}
- Password + {t("common.password")}
@@ -141,7 +143,7 @@ const Signin: React.FC = () => { className={`btn signin-btn ${actionBtnLoadingState.isLoading ? "requesting" : ""}`} onClick={() => handleSigninBtnsClick()} > - Sign in + {t("common.sign-in")} ) : (