diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index 1a9ea433a..b0b0ab15b 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -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 { diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index b892f9c09..5bf6833bd 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -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" + diff --git a/proto/gen/apidocs.swagger.yaml b/proto/gen/apidocs.swagger.yaml index 2b35bab33..823e56634 100644 --- a/proto/gen/apidocs.swagger.yaml +++ b/proto/gen/apidocs.swagger.yaml @@ -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 diff --git a/proto/gen/store/user_setting.pb.go b/proto/gen/store/user_setting.pb.go index 52d263d79..894aee70a 100644 --- a/proto/gen/store/user_setting.pb.go +++ b/proto/gen/store/user_setting.pb.go @@ -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" + diff --git a/proto/store/user_setting.proto b/proto/store/user_setting.proto index f2c30eaf4..7ec03b0bb 100644 --- a/proto/store/user_setting.proto +++ b/proto/store/user_setting.proto @@ -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 { diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index 93d5687c5..a7af29d3a 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -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) } diff --git a/server/router/api/v1/workspace_service.go b/server/router/api/v1/workspace_service.go index 2ca0a8d47..aed7f08cc 100644 --- a/server/router/api/v1/workspace_service.go +++ b/server/router/api/v1/workspace_service.go @@ -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, diff --git a/web/src/App.tsx b/web/src/App.tsx index 6ac72c4fa..1fbc6a53d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ; }); diff --git a/web/src/components/AppearanceSelect.tsx b/web/src/components/AppearanceSelect.tsx index 31838117d..5c679858c 100644 --- a/web/src/components/AppearanceSelect.tsx +++ b/web/src/components/AppearanceSelect.tsx @@ -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) => { - 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) => { return ( - +
diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx index f8659cade..bb9294003 100644 --- a/web/src/components/Settings/PreferencesSection.tsx +++ b/web/src/components/Settings/PreferencesSection.tsx @@ -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 (

{t("common.basic")}

@@ -41,6 +46,11 @@ const PreferencesSection = observer(() => {
+
+ {t("setting.preference-section.theme")} + +
+

{t("setting.preference")}

diff --git a/web/src/components/UpdateCustomizedProfileDialog.tsx b/web/src/components/UpdateCustomizedProfileDialog.tsx index 44e835955..fe0f38b2a 100644 --- a/web/src/components/UpdateCustomizedProfileDialog.tsx +++ b/web/src/components/UpdateCustomizedProfileDialog.tsx @@ -137,12 +137,12 @@ export function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }:
- +
- +
diff --git a/web/src/types/proto/api/v1/user_service.ts b/web/src/types/proto/api/v1/user_service.ts index c1e923671..4ec9a0798 100644 --- a/web/src/types/proto/api/v1/user_service.ts +++ b/web/src/types/proto/api/v1/user_service.ts @@ -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 = { }; function createBaseUserSetting(): UserSetting { - return { name: "", locale: "", appearance: "", memoVisibility: "" }; + return { name: "", locale: "", appearance: "", memoVisibility: "", theme: "" }; } export const UserSetting: MessageFns = { @@ -1549,6 +1555,9 @@ export const UserSetting: MessageFns = { 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 = { 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 = { message.locale = object.locale ?? ""; message.appearance = object.appearance ?? ""; message.memoVisibility = object.memoVisibility ?? ""; + message.theme = object.theme ?? ""; return message; }, };