feat: add user_setting model (#145)

* feat: add `user_setting` model

* chore: add global store

* chore: update settings in web

* chore: update `i18n` example
This commit is contained in:
boojack 2022-08-13 14:35:33 +08:00 committed by GitHub
parent dfac877957
commit 90b881502d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 449 additions and 63 deletions

View File

@ -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 {

34
api/user_setting.go Normal file
View File

@ -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
}

View File

@ -7,4 +7,4 @@ services:
- "5230:5230"
volumes:
- ~/.memos/:/var/opt/memos
command: --mode=prod --port=5230
command: --mode=prod --port=5230

View File

@ -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"))

View File

@ -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);

View File

@ -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);

122
store/user_setting.go Normal file
View File

@ -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
}

View File

@ -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)}</>;
}

View File

@ -10,8 +10,8 @@ import "../less/about-site-dialog.less";
interface Props extends DialogProps {}
const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
const { t } = useI18n();
const [profile, setProfile] = useState<Profile>();
const { t, setLocale } = useI18n();
useEffect(() => {
try {
@ -27,10 +27,6 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
version: "0.0.0",
});
}
setTimeout(() => {
setLocale("zh");
}, 2333);
}, []);
const handleCloseBtnClick = () => {
@ -42,7 +38,7 @@ const AboutSiteDialog: React.FC<Props> = ({ destroy }: Props) => {
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🤠</span>
{t("about")} <b>Memos</b>
{t("common.about")} <b>Memos</b>
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />

View File

@ -1,11 +1,27 @@
import { memoService } from "../../services";
import { globalService, memoService, userService } from "../../services";
import * as utils from "../../helpers/utils";
import { useAppSelector } from "../../store";
import Only from "../common/OnlyWhen";
import toastHelper from "../Toast";
import Selector from "../common/Selector";
import "../../less/settings/preferences-section.less";
interface Props {}
const localeSelectorItems = [
{
text: "English",
value: "en",
},
{
text: "中文",
value: "zh",
},
];
const PreferencesSection: React.FC<Props> = () => {
const { setting } = useAppSelector((state) => state.user.user as User);
const handleExportBtnClick = async () => {
const formatedMemos = memoService.getState().memos.map((m) => {
return {
@ -64,17 +80,29 @@ const PreferencesSection: React.FC<Props> = () => {
fileInputEl.click();
};
const handleLocaleChanged = async (value: string) => {
globalService.setLocale(value as Locale);
await userService.upsertUserSetting("locale", value);
};
return (
<div className="section-container preferences-section-container">
<p className="title-text">Others</p>
<div className="btns-container">
<button className="btn" onClick={handleExportBtnClick}>
Export data as JSON
</button>
<button className="btn" onClick={handleImportBtnClick}>
Import from JSON
</button>
</div>
{/* Hide export/import buttons */}
<label className="form-label">
<span className="normal-text">Language:</span>
<Selector className="ml-2 w-28" value={setting.locale} dataSource={localeSelectorItems} handleValueChanged={handleLocaleChanged} />
</label>
<Only when={false}>
<p className="title-text">Others</p>
<div className="btns-container">
<button className="btn" onClick={handleExportBtnClick}>
Export data as JSON
</button>
<button className="btn" onClick={handleImportBtnClick}>
Import from JSON
</button>
</div>
</Only>
</div>
);
};

View File

@ -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: Props) => {
}
}, [showSelector]);
const handleItemClick = (item: TVObject) => {
const handleItemClick = (item: SelectorItem) => {
if (handleValueChanged) {
handleValueChanged(item.value);
}

View File

@ -46,6 +46,10 @@ export function getUserById(id: number) {
return axios.get<ResponseObject<User>>(`/api/user/${id}`);
}
export function upsertUserSetting(upsert: UserSettingUpsert) {
return axios.post<ResponseObject<UserSetting>>(`/api/user/setting`, upsert);
}
export function patchUser(userPatch: UserPatch) {
return axios.patch<ResponseObject<User>>(`/api/user/${userPatch.id}`, userPatch);
}

View File

@ -4,9 +4,8 @@
interface StorageData {
// Editor content cache
editorContentCache: string;
shouldSplitMemoWord: boolean;
shouldHideImageUrl: boolean;
shouldUseMarkdownParser: boolean;
// locale
locale: Locale;
}
type StorageKey = keyof StorageData;

View File

@ -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;
}
};

View File

@ -1,3 +1,8 @@
{
"about": "About"
"common": {
"about": "About",
"email": "Email",
"password": "Password",
"sign-in": "Sign in"
}
}

View File

@ -1,3 +1,8 @@
{
"about": "关于"
"common": {
"about": "关于",
"email": "邮箱",
"password": "密码",
"sign-in": "登录"
}
}

View File

@ -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<Props> = () => {
const { t } = useI18n();
const pageLoadingState = useLoading(true);
const [siteHost, setSiteHost] = useState<User>();
const [email, setEmail] = useState("");
@ -127,11 +129,11 @@ const Signin: React.FC<Props> = () => {
</div>
<div className={`page-content-container ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}>
<div className="form-item-container input-form-container">
<span className={`normal-text ${email ? "not-null" : ""}`}>Email</span>
<span className={`normal-text ${email ? "not-null" : ""}`}>{t("common.email")}</span>
<input type="email" value={email} onChange={handleEmailInputChanged} />
</div>
<div className="form-item-container input-form-container">
<span className={`normal-text ${password ? "not-null" : ""}`}>Password</span>
<span className={`normal-text ${password ? "not-null" : ""}`}>{t("common.password")}</span>
<input type="password" value={password} onChange={handlePasswordInputChanged} />
</div>
</div>
@ -141,7 +143,7 @@ const Signin: React.FC<Props> = () => {
className={`btn signin-btn ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}
onClick={() => handleSigninBtnsClick()}
>
Sign in
{t("common.sign-in")}
</button>
) : (
<button

View File

@ -0,0 +1,41 @@
import store from "../store";
import * as api from "../helpers/api";
import * as storage from "../helpers/storage";
import { setGlobalState, setLocale } from "../store/modules/global";
import { convertResponseModelUser } from "./userService";
const globalService = {
getState: () => {
return store.getState().global;
},
initialState: async () => {
const defaultGlobalState = {
locale: "en" as Locale,
};
const { locale: storageLocale } = storage.get(["locale"]);
if (storageLocale) {
defaultGlobalState.locale = storageLocale;
}
try {
const { data } = (await api.getMyselfUser()).data;
if (data) {
const user = convertResponseModelUser(data);
if (user.setting.locale) {
defaultGlobalState.locale = user.setting.locale;
}
}
} catch (error) {
// do nth
}
store.dispatch(setGlobalState(defaultGlobalState));
},
setLocale: (locale: Locale) => {
store.dispatch(setLocale(locale));
},
};
export default globalService;

View File

@ -1,3 +1,4 @@
import globalService from "./globalService";
import editorStateService from "./editorStateService";
import locationService from "./locationService";
import memoService from "./memoService";
@ -5,4 +6,4 @@ import shortcutService from "./shortcutService";
import userService from "./userService";
import resourceService from "./resourceService";
export { editorStateService, locationService, memoService, shortcutService, userService, resourceService };
export { globalService, editorStateService, locationService, memoService, shortcutService, userService, resourceService };

View File

@ -4,9 +4,24 @@ import * as api from "../helpers/api";
import store from "../store";
import { setUser, patchUser, setHost, setOwner } from "../store/modules/user";
const convertResponseModelUser = (user: User): User => {
const defauleSetting: Setting = {
locale: "en",
};
export const convertResponseModelUser = (user: User): User => {
const setting: Setting = {
...defauleSetting,
};
if (user.userSettingList) {
for (const userSetting of user.userSettingList) {
setting[userSetting.key] = JSON.parse(userSetting.value);
}
}
return {
...user,
setting,
createdTs: user.createdTs * 1000,
updatedTs: user.updatedTs * 1000,
};
@ -76,6 +91,14 @@ const userService = {
}
},
upsertUserSetting: async (key: string, value: any) => {
await api.upsertUserSetting({
key: key as any,
value: JSON.stringify(value),
});
await userService.doSignIn();
},
patchUser: async (userPatch: UserPatch): Promise<void> => {
const { data } = (await api.patchUser(userPatch)).data;
if (userPatch.id === store.getState().user.user?.id) {

View File

@ -1,5 +1,6 @@
import { configureStore } from "@reduxjs/toolkit";
import { TypedUseSelectorHook, useSelector } from "react-redux";
import globalReducer from "./modules/global";
import userReducer from "./modules/user";
import memoReducer from "./modules/memo";
import editorReducer from "./modules/editor";
@ -8,6 +9,7 @@ import locationReducer from "./modules/location";
const store = configureStore({
reducer: {
global: globalReducer,
user: userReducer,
memo: memoReducer,
editor: editorReducer,

View File

@ -0,0 +1,25 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
locale: Locale;
}
const globalSlice = createSlice({
name: "global",
initialState: {} as State,
reducers: {
setGlobalState: (_, action: PayloadAction<State>) => {
return action.payload;
},
setLocale: (state, action: PayloadAction<Locale>) => {
return {
...state,
locale: action.payload,
};
},
},
});
export const { setGlobalState, setLocale } = globalSlice.actions;
export default globalSlice.reducer;

1
web/src/types/i18n.d.ts vendored Normal file
View File

@ -0,0 +1 @@
type Locale = "en" | "zh";

15
web/src/types/modules/setting.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
interface Setting {
locale: "en" | "zh";
}
interface UserLocaleSetting {
key: "locale";
value: "en" | "zh";
}
type UserSetting = UserLocaleSetting;
interface UserSettingUpsert {
key: keyof Setting;
value: string;
}

View File

@ -12,6 +12,9 @@ interface User {
email: string;
name: string;
openId: string;
userSettingList: UserSetting[];
setting: Setting;
}
interface UserCreate {