mirror of https://github.com/usememos/memos.git
chore: theme in user setting
This commit is contained in:
parent
f907619752
commit
533591af2b
|
|
@ -369,6 +369,11 @@ message UserSetting {
|
|||
|
||||
// The default visibility of the memo.
|
||||
string memo_visibility = 4 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// The preferred theme of the user.
|
||||
// This references a CSS file in the web/public/themes/ directory.
|
||||
// If not set, the default theme will be used.
|
||||
string theme = 5 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
message GetUserSettingRequest {
|
||||
|
|
|
|||
|
|
@ -943,8 +943,12 @@ type UserSetting struct {
|
|||
Appearance string `protobuf:"bytes,3,opt,name=appearance,proto3" json:"appearance,omitempty"`
|
||||
// The default visibility of the memo.
|
||||
MemoVisibility string `protobuf:"bytes,4,opt,name=memo_visibility,json=memoVisibility,proto3" json:"memo_visibility,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// The preferred theme of the user.
|
||||
// This references a CSS file in the web/public/themes/ directory.
|
||||
// If not set, the default theme will be used.
|
||||
Theme string `protobuf:"bytes,5,opt,name=theme,proto3" json:"theme,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *UserSetting) Reset() {
|
||||
|
|
@ -1005,6 +1009,13 @@ func (x *UserSetting) GetMemoVisibility() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *UserSetting) GetTheme() string {
|
||||
if x != nil {
|
||||
return x.Theme
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetUserSettingRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Required. The resource name of the user.
|
||||
|
|
@ -2005,14 +2016,15 @@ const file_api_v1_user_service_proto_rawDesc = "" +
|
|||
"\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStats\"D\n" +
|
||||
"\x13GetUserStatsRequest\x12-\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/UserR\x04name\"\xde\x01\n" +
|
||||
"\x11memos.api.v1/UserR\x04name\"\xf9\x01\n" +
|
||||
"\vUserSetting\x12\x17\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x1b\n" +
|
||||
"\x06locale\x18\x02 \x01(\tB\x03\xe0A\x01R\x06locale\x12#\n" +
|
||||
"\n" +
|
||||
"appearance\x18\x03 \x01(\tB\x03\xe0A\x01R\n" +
|
||||
"appearance\x12,\n" +
|
||||
"\x0fmemo_visibility\x18\x04 \x01(\tB\x03\xe0A\x01R\x0ememoVisibility:F\xeaAC\n" +
|
||||
"\x0fmemo_visibility\x18\x04 \x01(\tB\x03\xe0A\x01R\x0ememoVisibility\x12\x19\n" +
|
||||
"\x05theme\x18\x05 \x01(\tB\x03\xe0A\x01R\x05theme:F\xeaAC\n" +
|
||||
"\x18memos.api.v1/UserSetting\x12\fusers/{user}*\fuserSettings2\vuserSetting\"F\n" +
|
||||
"\x15GetUserSettingRequest\x12-\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
|
||||
|
|
|
|||
|
|
@ -2218,6 +2218,12 @@ paths:
|
|||
memoVisibility:
|
||||
type: string
|
||||
description: The default visibility of the memo.
|
||||
theme:
|
||||
type: string
|
||||
description: |-
|
||||
The preferred theme of the user.
|
||||
This references a CSS file in the web/public/themes/ directory.
|
||||
If not set, the default theme will be used.
|
||||
title: Required. The user setting to update.
|
||||
required:
|
||||
- setting
|
||||
|
|
@ -2866,6 +2872,12 @@ definitions:
|
|||
memoVisibility:
|
||||
type: string
|
||||
description: The default visibility of the memo.
|
||||
theme:
|
||||
type: string
|
||||
description: |-
|
||||
The preferred theme of the user.
|
||||
This references a CSS file in the web/public/themes/ directory.
|
||||
If not set, the default theme will be used.
|
||||
title: User settings message
|
||||
apiv1Webhook:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -239,8 +239,11 @@ type GeneralUserSetting struct {
|
|||
Appearance string `protobuf:"bytes,2,opt,name=appearance,proto3" json:"appearance,omitempty"`
|
||||
// The user's memo visibility setting.
|
||||
MemoVisibility string `protobuf:"bytes,3,opt,name=memo_visibility,json=memoVisibility,proto3" json:"memo_visibility,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// The user's theme preference.
|
||||
// This references a CSS file in the web/public/themes/ directory.
|
||||
Theme string `protobuf:"bytes,4,opt,name=theme,proto3" json:"theme,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GeneralUserSetting) Reset() {
|
||||
|
|
@ -294,6 +297,13 @@ func (x *GeneralUserSetting) GetMemoVisibility() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *GeneralUserSetting) GetTheme() string {
|
||||
if x != nil {
|
||||
return x.Theme
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SessionsUserSetting struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Sessions []*SessionsUserSetting_Session `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"`
|
||||
|
|
@ -822,13 +832,14 @@ const file_store_user_setting_proto_rawDesc = "" +
|
|||
"\rACCESS_TOKENS\x10\x03\x12\r\n" +
|
||||
"\tSHORTCUTS\x10\x04\x12\f\n" +
|
||||
"\bWEBHOOKS\x10\x05B\a\n" +
|
||||
"\x05value\"u\n" +
|
||||
"\x05value\"\x8b\x01\n" +
|
||||
"\x12GeneralUserSetting\x12\x16\n" +
|
||||
"\x06locale\x18\x01 \x01(\tR\x06locale\x12\x1e\n" +
|
||||
"\n" +
|
||||
"appearance\x18\x02 \x01(\tR\n" +
|
||||
"appearance\x12'\n" +
|
||||
"\x0fmemo_visibility\x18\x03 \x01(\tR\x0ememoVisibility\"\xf3\x03\n" +
|
||||
"\x0fmemo_visibility\x18\x03 \x01(\tR\x0ememoVisibility\x12\x14\n" +
|
||||
"\x05theme\x18\x04 \x01(\tR\x05theme\"\xf3\x03\n" +
|
||||
"\x13SessionsUserSetting\x12D\n" +
|
||||
"\bsessions\x18\x01 \x03(\v2(.memos.store.SessionsUserSetting.SessionR\bsessions\x1a\xfd\x01\n" +
|
||||
"\aSession\x12\x1d\n" +
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ message GeneralUserSetting {
|
|||
string appearance = 2;
|
||||
// The user's memo visibility setting.
|
||||
string memo_visibility = 3;
|
||||
// The user's theme preference.
|
||||
// This references a CSS file in the web/public/themes/ directory.
|
||||
string theme = 4;
|
||||
}
|
||||
|
||||
message SessionsUserSetting {
|
||||
|
|
|
|||
|
|
@ -249,7 +249,8 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
|
|||
return nil, status.Errorf(codes.Internal, "failed to get workspace general setting: %v", err)
|
||||
}
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "username" {
|
||||
switch field {
|
||||
case "username":
|
||||
if workspaceGeneralSetting.DisallowChangeUsername {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change username")
|
||||
}
|
||||
|
|
@ -257,35 +258,35 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
|
|||
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
|
||||
}
|
||||
update.Username = &request.User.Username
|
||||
} else if field == "display_name" {
|
||||
case "display_name":
|
||||
if workspaceGeneralSetting.DisallowChangeNickname {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change nickname")
|
||||
}
|
||||
update.Nickname = &request.User.DisplayName
|
||||
} else if field == "email" {
|
||||
case "email":
|
||||
update.Email = &request.User.Email
|
||||
} else if field == "avatar_url" {
|
||||
case "avatar_url":
|
||||
update.AvatarURL = &request.User.AvatarUrl
|
||||
} else if field == "description" {
|
||||
case "description":
|
||||
update.Description = &request.User.Description
|
||||
} else if field == "role" {
|
||||
case "role":
|
||||
// Only allow admin to update role.
|
||||
if currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
role := convertUserRoleToStore(request.User.Role)
|
||||
update.Role = &role
|
||||
} else if field == "password" {
|
||||
case "password":
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
passwordHashStr := string(passwordHash)
|
||||
update.PasswordHash = &passwordHashStr
|
||||
} else if field == "state" {
|
||||
case "state":
|
||||
rowStatus := convertStateToStore(request.User.State)
|
||||
update.RowStatus = &rowStatus
|
||||
} else {
|
||||
default:
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
|
||||
}
|
||||
}
|
||||
|
|
@ -334,6 +335,7 @@ func getDefaultUserSetting() *v1pb.UserSetting {
|
|||
Locale: "en",
|
||||
Appearance: "system",
|
||||
MemoVisibility: "PRIVATE",
|
||||
Theme: "",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,9 +372,24 @@ func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUser
|
|||
userSettingMessage.Locale = general.Locale
|
||||
userSettingMessage.Appearance = general.Appearance
|
||||
userSettingMessage.MemoVisibility = general.MemoVisibility
|
||||
userSettingMessage.Theme = general.Theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill theme if empty: use workspace theme or default to "default"
|
||||
if userSettingMessage.Theme == "" {
|
||||
workspaceGeneralSetting, err := s.Store.GetWorkspaceGeneralSetting(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get workspace general setting: %v", err)
|
||||
}
|
||||
workspaceTheme := workspaceGeneralSetting.Theme
|
||||
if workspaceTheme == "" {
|
||||
workspaceTheme = "default"
|
||||
}
|
||||
userSettingMessage.Theme = workspaceTheme
|
||||
}
|
||||
|
||||
return userSettingMessage, nil
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +428,7 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
|
|||
Locale: "en",
|
||||
Appearance: "system",
|
||||
MemoVisibility: "PRIVATE",
|
||||
Theme: "",
|
||||
}
|
||||
|
||||
// If there's an existing setting, use its values as defaults
|
||||
|
|
@ -419,6 +437,7 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
|
|||
generalSetting.Locale = existing.Locale
|
||||
generalSetting.Appearance = existing.Appearance
|
||||
generalSetting.MemoVisibility = existing.MemoVisibility
|
||||
generalSetting.Theme = existing.Theme
|
||||
}
|
||||
|
||||
// Apply updates based on the update mask
|
||||
|
|
@ -430,6 +449,8 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
|
|||
generalSetting.Appearance = request.Setting.Appearance
|
||||
case "memo_visibility":
|
||||
generalSetting.MemoVisibility = request.Setting.MemoVisibility
|
||||
case "theme":
|
||||
generalSetting.Theme = request.Setting.Theme
|
||||
default:
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,8 +149,14 @@ func convertWorkspaceGeneralSettingFromStore(setting *storepb.WorkspaceGeneralSe
|
|||
if setting == nil {
|
||||
return nil
|
||||
}
|
||||
// Backfill theme if empty
|
||||
theme := setting.Theme
|
||||
if theme == "" {
|
||||
theme = "default"
|
||||
}
|
||||
|
||||
generalSetting := &v1pb.WorkspaceGeneralSetting{
|
||||
Theme: setting.Theme,
|
||||
Theme: theme,
|
||||
DisallowUserRegistration: setting.DisallowUserRegistration,
|
||||
DisallowPasswordAuth: setting.DisallowPasswordAuth,
|
||||
AdditionalScript: setting.AdditionalScript,
|
||||
|
|
|
|||
|
|
@ -104,10 +104,12 @@ const App = observer(() => {
|
|||
});
|
||||
}, [userSetting?.locale, userSetting?.appearance]);
|
||||
|
||||
// Load theme when workspace setting changes, validate API response
|
||||
// Load theme when user setting changes (user theme is already backfilled with workspace theme)
|
||||
useEffect(() => {
|
||||
loadTheme(workspaceGeneralSetting.theme);
|
||||
}, [workspaceGeneralSetting.theme]);
|
||||
if (userSetting?.theme) {
|
||||
loadTheme(userSetting.theme);
|
||||
}
|
||||
}, [userSetting?.theme]);
|
||||
|
||||
return <Outlet />;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ import { useTranslate } from "@/utils/i18n";
|
|||
interface Props {
|
||||
value: Appearance;
|
||||
onChange: (appearance: Appearance) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const appearanceList = ["system", "light", "dark"] as const;
|
||||
|
||||
const AppearanceSelect: FC<Props> = (props: Props) => {
|
||||
const { onChange, value, className } = props;
|
||||
const { onChange, value } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
const getPrefixIcon = (appearance: Appearance) => {
|
||||
|
|
@ -32,7 +31,7 @@ const AppearanceSelect: FC<Props> = (props: Props) => {
|
|||
|
||||
return (
|
||||
<Select value={value} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className={`min-w-40 w-auto whitespace-nowrap ${className ?? ""}`}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select appearance" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ import { locales } from "@/i18n";
|
|||
|
||||
interface Props {
|
||||
value: Locale;
|
||||
className?: string;
|
||||
onChange: (locale: Locale) => void;
|
||||
}
|
||||
|
||||
const LocaleSelect: FC<Props> = (props: Props) => {
|
||||
const { onChange, value, className } = props;
|
||||
const { onChange, value } = props;
|
||||
|
||||
const handleSelectChange = async (locale: Locale) => {
|
||||
onChange(locale);
|
||||
|
|
@ -18,7 +17,7 @@ const LocaleSelect: FC<Props> = (props: Props) => {
|
|||
|
||||
return (
|
||||
<Select value={value} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className={`min-w-40 w-auto whitespace-nowrap ${className ?? ""}`}>
|
||||
<SelectTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon className="w-4 h-auto" />
|
||||
<SelectValue placeholder="Select language" />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useTranslate } from "@/utils/i18n";
|
|||
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
|
||||
import AppearanceSelect from "../AppearanceSelect";
|
||||
import LocaleSelect from "../LocaleSelect";
|
||||
import ThemeSelector from "../ThemeSelector";
|
||||
import VisibilityIcon from "../VisibilityIcon";
|
||||
import WebhookSection from "./WebhookSection";
|
||||
|
||||
|
|
@ -27,6 +28,10 @@ const PreferencesSection = observer(() => {
|
|||
await userStore.updateUserSetting({ memoVisibility: value }, ["memo_visibility"]);
|
||||
};
|
||||
|
||||
const handleThemeChange = async (theme: string) => {
|
||||
await userStore.updateUserSetting({ theme }, ["theme"]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 pt-2 pb-4">
|
||||
<p className="font-medium text-muted-foreground">{t("common.basic")}</p>
|
||||
|
|
@ -41,6 +46,11 @@ const PreferencesSection = observer(() => {
|
|||
<AppearanceSelect value={setting.appearance as Appearance} onChange={handleAppearanceSelectChange} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span>{t("setting.preference-section.theme")}</span>
|
||||
<ThemeSelector value={setting.theme} onValueChange={handleThemeChange} />
|
||||
</div>
|
||||
|
||||
<p className="font-medium text-muted-foreground">{t("setting.preference")}</p>
|
||||
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
|
|
|
|||
|
|
@ -137,12 +137,12 @@ export function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }:
|
|||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.system-section.customize-server.locale")}</Label>
|
||||
<LocaleSelect className="w-full" value={customProfile.locale} onChange={handleLocaleSelectChange} />
|
||||
<LocaleSelect value={customProfile.locale} onChange={handleLocaleSelectChange} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.system-section.customize-server.appearance")}</Label>
|
||||
<AppearanceSelect className="w-full" value={customProfile.appearance as Appearance} onChange={handleAppearanceSelectChange} />
|
||||
<AppearanceSelect value={customProfile.appearance as Appearance} onChange={handleAppearanceSelectChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -272,6 +272,12 @@ export interface UserSetting {
|
|||
appearance: string;
|
||||
/** The default visibility of the memo. */
|
||||
memoVisibility: string;
|
||||
/**
|
||||
* The preferred theme of the user.
|
||||
* This references a CSS file in the web/public/themes/ directory.
|
||||
* If not set, the default theme will be used.
|
||||
*/
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export interface GetUserSettingRequest {
|
||||
|
|
@ -1532,7 +1538,7 @@ export const GetUserStatsRequest: MessageFns<GetUserStatsRequest> = {
|
|||
};
|
||||
|
||||
function createBaseUserSetting(): UserSetting {
|
||||
return { name: "", locale: "", appearance: "", memoVisibility: "" };
|
||||
return { name: "", locale: "", appearance: "", memoVisibility: "", theme: "" };
|
||||
}
|
||||
|
||||
export const UserSetting: MessageFns<UserSetting> = {
|
||||
|
|
@ -1549,6 +1555,9 @@ export const UserSetting: MessageFns<UserSetting> = {
|
|||
if (message.memoVisibility !== "") {
|
||||
writer.uint32(34).string(message.memoVisibility);
|
||||
}
|
||||
if (message.theme !== "") {
|
||||
writer.uint32(42).string(message.theme);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
|
|
@ -1591,6 +1600,14 @@ export const UserSetting: MessageFns<UserSetting> = {
|
|||
message.memoVisibility = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.theme = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
|
|
@ -1609,6 +1626,7 @@ export const UserSetting: MessageFns<UserSetting> = {
|
|||
message.locale = object.locale ?? "";
|
||||
message.appearance = object.appearance ?? "";
|
||||
message.memoVisibility = object.memoVisibility ?? "";
|
||||
message.theme = object.theme ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue