diff --git a/api/user.go b/api/user.go index 13625623..5d1634d6 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 00000000..ff5b0bb7 --- /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 c880a71f..3360f1e8 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 e6dfb63b..8210e91e 100644 --- a/server/user.go +++ b/server/user.go @@ -58,6 +58,66 @@ 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 { + 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, + } + user, err := s.Store.FindUser(ctx, userFind) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err) + } + + 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 { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode user response").SetInternal(err) + } + return nil + }) + + 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") + } + + 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) + } + + 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")) @@ -84,29 +144,6 @@ 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 { - 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, - } - user, err := s.Store.FindUser(ctx, userFind) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch user").SetInternal(err) - } - - 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) - } - return nil - }) - g.PATCH("/user/:id", func(c echo.Context) error { ctx := c.Request().Context() userID, err := strconv.Atoi(c.Param("id")) diff --git a/store/db/migration/dev/LATEST__SCHEMA.sql b/store/db/migration/dev/LATEST__SCHEMA.sql index ca0bb148..5c1bc47e 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 00000000..be65387f --- /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 00000000..2f32a1f1 --- /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 19bd2a61..e67a278a 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 fa4ed6ed..869baa0b 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 d1bf80be..4224845d 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 5e47b9c8..88690370 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 391539aa..e767ecfe 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 0334b578..b005dd34 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 287c80f4..7c9be418 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 283465b6..92259f0b 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 70399a29..6395e80c 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")} ) : (