refactor: simplify theme/locale to user preferences and improve initialization

Remove theme and locale from instance settings to eliminate duplication and
simplify the codebase. These are user-specific preferences and should only
exist in user settings, not instance-wide settings.

Backend changes:
- Remove theme from InstanceGeneralSetting proto
- Remove locale from InstanceCustomProfile proto
- Update instance service converters to remove theme/locale handling
- Simplify RSS feed to use static locale

Frontend changes:
- Remove theme/locale from instanceStore state
- Create unified initialization flow with clear fallback priority:
  * Theme: user setting → localStorage → system preference
  * Locale: user setting → browser language
- Add applyUserPreferences() to centralize theme/locale application
- Simplify App.tsx by removing redundant state synchronization
- Update all components to use new helper functions:
  * getThemeWithFallback() for theme resolution
  * getLocaleWithFallback() for locale resolution
- Remove theme/locale selectors from instance profile dialog

Theme utilities refactor:
- Organize code into clear sections with JSDoc comments
- Extract localStorage operations into getStoredTheme/setStoredTheme helpers
- Split DOM manipulation into focused functions
- Improve type safety with Theme and ResolvedTheme types
- Reduce code duplication and improve maintainability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steven 2025-12-02 09:08:46 +08:00
parent 8154a411a9
commit 81da20c905
31 changed files with 363 additions and 439 deletions

View File

@ -83,9 +83,6 @@ message InstanceSetting {
// General instance settings configuration.
message GeneralSetting {
// theme is the name of the selected theme.
// This references a CSS file in the web/public/themes/ directory.
string theme = 1;
// disallow_user_registration disallows user registration.
bool disallow_user_registration = 2;
// disallow_password_auth disallows password authentication.
@ -111,7 +108,6 @@ message InstanceSetting {
string title = 1;
string description = 2;
string logo_url = 3;
string locale = 4;
}
}

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/activity_service.proto
@ -80,10 +80,10 @@ type ActivityServiceServer interface {
type UnimplementedActivityServiceServer struct{}
func (UnimplementedActivityServiceServer) ListActivities(context.Context, *ListActivitiesRequest) (*ListActivitiesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListActivities not implemented")
return nil, status.Error(codes.Unimplemented, "method ListActivities not implemented")
}
func (UnimplementedActivityServiceServer) GetActivity(context.Context, *GetActivityRequest) (*Activity, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetActivity not implemented")
return nil, status.Error(codes.Unimplemented, "method GetActivity not implemented")
}
func (UnimplementedActivityServiceServer) mustEmbedUnimplementedActivityServiceServer() {}
func (UnimplementedActivityServiceServer) testEmbeddedByValue() {}
@ -96,7 +96,7 @@ type UnsafeActivityServiceServer interface {
}
func RegisterActivityServiceServer(s grpc.ServiceRegistrar, srv ActivityServiceServer) {
// If the following call pancis, it indicates UnimplementedActivityServiceServer was
// If the following call panics, it indicates UnimplementedActivityServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/attachment_service.proto
@ -142,22 +142,22 @@ type AttachmentServiceServer interface {
type UnimplementedAttachmentServiceServer struct{}
func (UnimplementedAttachmentServiceServer) CreateAttachment(context.Context, *CreateAttachmentRequest) (*Attachment, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateAttachment not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateAttachment not implemented")
}
func (UnimplementedAttachmentServiceServer) ListAttachments(context.Context, *ListAttachmentsRequest) (*ListAttachmentsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListAttachments not implemented")
return nil, status.Error(codes.Unimplemented, "method ListAttachments not implemented")
}
func (UnimplementedAttachmentServiceServer) GetAttachment(context.Context, *GetAttachmentRequest) (*Attachment, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetAttachment not implemented")
return nil, status.Error(codes.Unimplemented, "method GetAttachment not implemented")
}
func (UnimplementedAttachmentServiceServer) GetAttachmentBinary(context.Context, *GetAttachmentBinaryRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetAttachmentBinary not implemented")
return nil, status.Error(codes.Unimplemented, "method GetAttachmentBinary not implemented")
}
func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateAttachment not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateAttachment not implemented")
}
func (UnimplementedAttachmentServiceServer) DeleteAttachment(context.Context, *DeleteAttachmentRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteAttachment not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteAttachment not implemented")
}
func (UnimplementedAttachmentServiceServer) mustEmbedUnimplementedAttachmentServiceServer() {}
func (UnimplementedAttachmentServiceServer) testEmbeddedByValue() {}
@ -170,7 +170,7 @@ type UnsafeAttachmentServiceServer interface {
}
func RegisterAttachmentServiceServer(s grpc.ServiceRegistrar, srv AttachmentServiceServer) {
// If the following call pancis, it indicates UnimplementedAttachmentServiceServer was
// If the following call panics, it indicates UnimplementedAttachmentServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/auth_service.proto
@ -102,13 +102,13 @@ type AuthServiceServer interface {
type UnimplementedAuthServiceServer struct{}
func (UnimplementedAuthServiceServer) GetCurrentSession(context.Context, *GetCurrentSessionRequest) (*GetCurrentSessionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCurrentSession not implemented")
return nil, status.Error(codes.Unimplemented, "method GetCurrentSession not implemented")
}
func (UnimplementedAuthServiceServer) CreateSession(context.Context, *CreateSessionRequest) (*CreateSessionResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateSession not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateSession not implemented")
}
func (UnimplementedAuthServiceServer) DeleteSession(context.Context, *DeleteSessionRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteSession not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteSession not implemented")
}
func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {}
func (UnimplementedAuthServiceServer) testEmbeddedByValue() {}
@ -121,7 +121,7 @@ type UnsafeAuthServiceServer interface {
}
func RegisterAuthServiceServer(s grpc.ServiceRegistrar, srv AuthServiceServer) {
// If the following call pancis, it indicates UnimplementedAuthServiceServer was
// If the following call panics, it indicates UnimplementedAuthServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/idp_service.proto
@ -126,19 +126,19 @@ type IdentityProviderServiceServer interface {
type UnimplementedIdentityProviderServiceServer struct{}
func (UnimplementedIdentityProviderServiceServer) ListIdentityProviders(context.Context, *ListIdentityProvidersRequest) (*ListIdentityProvidersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListIdentityProviders not implemented")
return nil, status.Error(codes.Unimplemented, "method ListIdentityProviders not implemented")
}
func (UnimplementedIdentityProviderServiceServer) GetIdentityProvider(context.Context, *GetIdentityProviderRequest) (*IdentityProvider, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetIdentityProvider not implemented")
return nil, status.Error(codes.Unimplemented, "method GetIdentityProvider not implemented")
}
func (UnimplementedIdentityProviderServiceServer) CreateIdentityProvider(context.Context, *CreateIdentityProviderRequest) (*IdentityProvider, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateIdentityProvider not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateIdentityProvider not implemented")
}
func (UnimplementedIdentityProviderServiceServer) UpdateIdentityProvider(context.Context, *UpdateIdentityProviderRequest) (*IdentityProvider, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateIdentityProvider not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateIdentityProvider not implemented")
}
func (UnimplementedIdentityProviderServiceServer) DeleteIdentityProvider(context.Context, *DeleteIdentityProviderRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteIdentityProvider not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteIdentityProvider not implemented")
}
func (UnimplementedIdentityProviderServiceServer) mustEmbedUnimplementedIdentityProviderServiceServer() {
}
@ -152,7 +152,7 @@ type UnsafeIdentityProviderServiceServer interface {
}
func RegisterIdentityProviderServiceServer(s grpc.ServiceRegistrar, srv IdentityProviderServiceServer) {
// If the following call pancis, it indicates UnimplementedIdentityProviderServiceServer was
// If the following call panics, it indicates UnimplementedIdentityProviderServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -460,9 +460,6 @@ func (x *UpdateInstanceSettingRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
// General instance settings configuration.
type InstanceSetting_GeneralSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
// theme is the name of the selected theme.
// This references a CSS file in the web/public/themes/ directory.
Theme string `protobuf:"bytes,1,opt,name=theme,proto3" json:"theme,omitempty"`
// disallow_user_registration disallows user registration.
DisallowUserRegistration bool `protobuf:"varint,2,opt,name=disallow_user_registration,json=disallowUserRegistration,proto3" json:"disallow_user_registration,omitempty"`
// disallow_password_auth disallows password authentication.
@ -515,13 +512,6 @@ func (*InstanceSetting_GeneralSetting) Descriptor() ([]byte, []int) {
return file_api_v1_instance_service_proto_rawDescGZIP(), []int{2, 0}
}
func (x *InstanceSetting_GeneralSetting) GetTheme() string {
if x != nil {
return x.Theme
}
return ""
}
func (x *InstanceSetting_GeneralSetting) GetDisallowUserRegistration() bool {
if x != nil {
return x.DisallowUserRegistration
@ -758,7 +748,6 @@ type InstanceSetting_GeneralSetting_CustomProfile struct {
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
LogoUrl string `protobuf:"bytes,3,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"`
Locale string `protobuf:"bytes,4,opt,name=locale,proto3" json:"locale,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -814,13 +803,6 @@ func (x *InstanceSetting_GeneralSetting_CustomProfile) GetLogoUrl() string {
return ""
}
func (x *InstanceSetting_GeneralSetting_CustomProfile) GetLocale() string {
if x != nil {
return x.Locale
}
return ""
}
// S3 configuration for cloud storage backend.
// Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/
type InstanceSetting_StorageSetting_S3Config struct {
@ -917,14 +899,13 @@ const file_api_v1_instance_service_proto_rawDesc = "" +
"\aversion\x18\x02 \x01(\tR\aversion\x12\x12\n" +
"\x04mode\x18\x03 \x01(\tR\x04mode\x12!\n" +
"\finstance_url\x18\x06 \x01(\tR\vinstanceUrl\"\x1b\n" +
"\x19GetInstanceProfileRequest\"\x9d\x10\n" +
"\x19GetInstanceProfileRequest\"\xef\x0f\n" +
"\x0fInstanceSetting\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12W\n" +
"\x0fgeneral_setting\x18\x02 \x01(\v2,.memos.api.v1.InstanceSetting.GeneralSettingH\x00R\x0egeneralSetting\x12W\n" +
"\x0fstorage_setting\x18\x03 \x01(\v2,.memos.api.v1.InstanceSetting.StorageSettingH\x00R\x0estorageSetting\x12d\n" +
"\x14memo_related_setting\x18\x04 \x01(\v20.memos.api.v1.InstanceSetting.MemoRelatedSettingH\x00R\x12memoRelatedSetting\x1a\xf8\x04\n" +
"\x0eGeneralSetting\x12\x14\n" +
"\x05theme\x18\x01 \x01(\tR\x05theme\x12<\n" +
"\x14memo_related_setting\x18\x04 \x01(\v20.memos.api.v1.InstanceSetting.MemoRelatedSettingH\x00R\x12memoRelatedSetting\x1a\xca\x04\n" +
"\x0eGeneralSetting\x12<\n" +
"\x1adisallow_user_registration\x18\x02 \x01(\bR\x18disallowUserRegistration\x124\n" +
"\x16disallow_password_auth\x18\x03 \x01(\bR\x14disallowPasswordAuth\x12+\n" +
"\x11additional_script\x18\x04 \x01(\tR\x10additionalScript\x12)\n" +
@ -932,12 +913,11 @@ const file_api_v1_instance_service_proto_rawDesc = "" +
"\x0ecustom_profile\x18\x06 \x01(\v2:.memos.api.v1.InstanceSetting.GeneralSetting.CustomProfileR\rcustomProfile\x121\n" +
"\x15week_start_day_offset\x18\a \x01(\x05R\x12weekStartDayOffset\x128\n" +
"\x18disallow_change_username\x18\b \x01(\bR\x16disallowChangeUsername\x128\n" +
"\x18disallow_change_nickname\x18\t \x01(\bR\x16disallowChangeNickname\x1az\n" +
"\x18disallow_change_nickname\x18\t \x01(\bR\x16disallowChangeNickname\x1ab\n" +
"\rCustomProfile\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" +
"\blogo_url\x18\x03 \x01(\tR\alogoUrl\x12\x16\n" +
"\x06locale\x18\x04 \x01(\tR\x06locale\x1a\xbc\x04\n" +
"\blogo_url\x18\x03 \x01(\tR\alogoUrl\x1a\xbc\x04\n" +
"\x0eStorageSetting\x12[\n" +
"\fstorage_type\x18\x01 \x01(\x0e28.memos.api.v1.InstanceSetting.StorageSetting.StorageTypeR\vstorageType\x12+\n" +
"\x11filepath_template\x18\x02 \x01(\tR\x10filepathTemplate\x12/\n" +

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/instance_service.proto
@ -95,13 +95,13 @@ type InstanceServiceServer interface {
type UnimplementedInstanceServiceServer struct{}
func (UnimplementedInstanceServiceServer) GetInstanceProfile(context.Context, *GetInstanceProfileRequest) (*InstanceProfile, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetInstanceProfile not implemented")
return nil, status.Error(codes.Unimplemented, "method GetInstanceProfile not implemented")
}
func (UnimplementedInstanceServiceServer) GetInstanceSetting(context.Context, *GetInstanceSettingRequest) (*InstanceSetting, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetInstanceSetting not implemented")
return nil, status.Error(codes.Unimplemented, "method GetInstanceSetting not implemented")
}
func (UnimplementedInstanceServiceServer) UpdateInstanceSetting(context.Context, *UpdateInstanceSettingRequest) (*InstanceSetting, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateInstanceSetting not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateInstanceSetting not implemented")
}
func (UnimplementedInstanceServiceServer) mustEmbedUnimplementedInstanceServiceServer() {}
func (UnimplementedInstanceServiceServer) testEmbeddedByValue() {}
@ -114,7 +114,7 @@ type UnsafeInstanceServiceServer interface {
}
func RegisterInstanceServiceServer(s grpc.ServiceRegistrar, srv InstanceServiceServer) {
// If the following call pancis, it indicates UnimplementedInstanceServiceServer was
// If the following call panics, it indicates UnimplementedInstanceServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/memo_service.proto
@ -261,46 +261,46 @@ type MemoServiceServer interface {
type UnimplementedMemoServiceServer struct{}
func (UnimplementedMemoServiceServer) CreateMemo(context.Context, *CreateMemoRequest) (*Memo, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateMemo not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateMemo not implemented")
}
func (UnimplementedMemoServiceServer) ListMemos(context.Context, *ListMemosRequest) (*ListMemosResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMemos not implemented")
return nil, status.Error(codes.Unimplemented, "method ListMemos not implemented")
}
func (UnimplementedMemoServiceServer) GetMemo(context.Context, *GetMemoRequest) (*Memo, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetMemo not implemented")
return nil, status.Error(codes.Unimplemented, "method GetMemo not implemented")
}
func (UnimplementedMemoServiceServer) UpdateMemo(context.Context, *UpdateMemoRequest) (*Memo, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateMemo not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateMemo not implemented")
}
func (UnimplementedMemoServiceServer) DeleteMemo(context.Context, *DeleteMemoRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteMemo not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteMemo not implemented")
}
func (UnimplementedMemoServiceServer) SetMemoAttachments(context.Context, *SetMemoAttachmentsRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetMemoAttachments not implemented")
return nil, status.Error(codes.Unimplemented, "method SetMemoAttachments not implemented")
}
func (UnimplementedMemoServiceServer) ListMemoAttachments(context.Context, *ListMemoAttachmentsRequest) (*ListMemoAttachmentsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMemoAttachments not implemented")
return nil, status.Error(codes.Unimplemented, "method ListMemoAttachments not implemented")
}
func (UnimplementedMemoServiceServer) SetMemoRelations(context.Context, *SetMemoRelationsRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetMemoRelations not implemented")
return nil, status.Error(codes.Unimplemented, "method SetMemoRelations not implemented")
}
func (UnimplementedMemoServiceServer) ListMemoRelations(context.Context, *ListMemoRelationsRequest) (*ListMemoRelationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMemoRelations not implemented")
return nil, status.Error(codes.Unimplemented, "method ListMemoRelations not implemented")
}
func (UnimplementedMemoServiceServer) CreateMemoComment(context.Context, *CreateMemoCommentRequest) (*Memo, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateMemoComment not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateMemoComment not implemented")
}
func (UnimplementedMemoServiceServer) ListMemoComments(context.Context, *ListMemoCommentsRequest) (*ListMemoCommentsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMemoComments not implemented")
return nil, status.Error(codes.Unimplemented, "method ListMemoComments not implemented")
}
func (UnimplementedMemoServiceServer) ListMemoReactions(context.Context, *ListMemoReactionsRequest) (*ListMemoReactionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListMemoReactions not implemented")
return nil, status.Error(codes.Unimplemented, "method ListMemoReactions not implemented")
}
func (UnimplementedMemoServiceServer) UpsertMemoReaction(context.Context, *UpsertMemoReactionRequest) (*Reaction, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpsertMemoReaction not implemented")
return nil, status.Error(codes.Unimplemented, "method UpsertMemoReaction not implemented")
}
func (UnimplementedMemoServiceServer) DeleteMemoReaction(context.Context, *DeleteMemoReactionRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteMemoReaction not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteMemoReaction not implemented")
}
func (UnimplementedMemoServiceServer) mustEmbedUnimplementedMemoServiceServer() {}
func (UnimplementedMemoServiceServer) testEmbeddedByValue() {}
@ -313,7 +313,7 @@ type UnsafeMemoServiceServer interface {
}
func RegisterMemoServiceServer(s grpc.ServiceRegistrar, srv MemoServiceServer) {
// If the following call pancis, it indicates UnimplementedMemoServiceServer was
// If the following call panics, it indicates UnimplementedMemoServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/shortcut_service.proto
@ -126,19 +126,19 @@ type ShortcutServiceServer interface {
type UnimplementedShortcutServiceServer struct{}
func (UnimplementedShortcutServiceServer) ListShortcuts(context.Context, *ListShortcutsRequest) (*ListShortcutsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListShortcuts not implemented")
return nil, status.Error(codes.Unimplemented, "method ListShortcuts not implemented")
}
func (UnimplementedShortcutServiceServer) GetShortcut(context.Context, *GetShortcutRequest) (*Shortcut, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetShortcut not implemented")
return nil, status.Error(codes.Unimplemented, "method GetShortcut not implemented")
}
func (UnimplementedShortcutServiceServer) CreateShortcut(context.Context, *CreateShortcutRequest) (*Shortcut, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateShortcut not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateShortcut not implemented")
}
func (UnimplementedShortcutServiceServer) UpdateShortcut(context.Context, *UpdateShortcutRequest) (*Shortcut, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateShortcut not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateShortcut not implemented")
}
func (UnimplementedShortcutServiceServer) DeleteShortcut(context.Context, *DeleteShortcutRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteShortcut not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteShortcut not implemented")
}
func (UnimplementedShortcutServiceServer) mustEmbedUnimplementedShortcutServiceServer() {}
func (UnimplementedShortcutServiceServer) testEmbeddedByValue() {}
@ -151,7 +151,7 @@ type UnsafeShortcutServiceServer interface {
}
func RegisterShortcutServiceServer(s grpc.ServiceRegistrar, srv ShortcutServiceServer) {
// If the following call pancis, it indicates UnimplementedShortcutServiceServer was
// If the following call panics, it indicates UnimplementedShortcutServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc-gen-go-grpc v1.6.0
// - protoc (unknown)
// source: api/v1/user_service.proto
@ -403,73 +403,73 @@ type UserServiceServer interface {
type UnimplementedUserServiceServer struct{}
func (UnimplementedUserServiceServer) ListUsers(context.Context, *ListUsersRequest) (*ListUsersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUsers not implemented")
return nil, status.Error(codes.Unimplemented, "method ListUsers not implemented")
}
func (UnimplementedUserServiceServer) GetUser(context.Context, *GetUserRequest) (*User, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
return nil, status.Error(codes.Unimplemented, "method GetUser not implemented")
}
func (UnimplementedUserServiceServer) CreateUser(context.Context, *CreateUserRequest) (*User, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateUser not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateUser not implemented")
}
func (UnimplementedUserServiceServer) UpdateUser(context.Context, *UpdateUserRequest) (*User, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUser not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateUser not implemented")
}
func (UnimplementedUserServiceServer) DeleteUser(context.Context, *DeleteUserRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteUser not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteUser not implemented")
}
func (UnimplementedUserServiceServer) GetUserAvatar(context.Context, *GetUserAvatarRequest) (*httpbody.HttpBody, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserAvatar not implemented")
return nil, status.Error(codes.Unimplemented, "method GetUserAvatar not implemented")
}
func (UnimplementedUserServiceServer) ListAllUserStats(context.Context, *ListAllUserStatsRequest) (*ListAllUserStatsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListAllUserStats not implemented")
return nil, status.Error(codes.Unimplemented, "method ListAllUserStats not implemented")
}
func (UnimplementedUserServiceServer) GetUserStats(context.Context, *GetUserStatsRequest) (*UserStats, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserStats not implemented")
return nil, status.Error(codes.Unimplemented, "method GetUserStats not implemented")
}
func (UnimplementedUserServiceServer) GetUserSetting(context.Context, *GetUserSettingRequest) (*UserSetting, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserSetting not implemented")
return nil, status.Error(codes.Unimplemented, "method GetUserSetting not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserSetting(context.Context, *UpdateUserSettingRequest) (*UserSetting, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserSetting not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateUserSetting not implemented")
}
func (UnimplementedUserServiceServer) ListUserSettings(context.Context, *ListUserSettingsRequest) (*ListUserSettingsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserSettings not implemented")
return nil, status.Error(codes.Unimplemented, "method ListUserSettings not implemented")
}
func (UnimplementedUserServiceServer) ListUserAccessTokens(context.Context, *ListUserAccessTokensRequest) (*ListUserAccessTokensResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserAccessTokens not implemented")
return nil, status.Error(codes.Unimplemented, "method ListUserAccessTokens not implemented")
}
func (UnimplementedUserServiceServer) CreateUserAccessToken(context.Context, *CreateUserAccessTokenRequest) (*UserAccessToken, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateUserAccessToken not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateUserAccessToken not implemented")
}
func (UnimplementedUserServiceServer) DeleteUserAccessToken(context.Context, *DeleteUserAccessTokenRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteUserAccessToken not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteUserAccessToken not implemented")
}
func (UnimplementedUserServiceServer) ListUserSessions(context.Context, *ListUserSessionsRequest) (*ListUserSessionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserSessions not implemented")
return nil, status.Error(codes.Unimplemented, "method ListUserSessions not implemented")
}
func (UnimplementedUserServiceServer) RevokeUserSession(context.Context, *RevokeUserSessionRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method RevokeUserSession not implemented")
return nil, status.Error(codes.Unimplemented, "method RevokeUserSession not implemented")
}
func (UnimplementedUserServiceServer) ListUserWebhooks(context.Context, *ListUserWebhooksRequest) (*ListUserWebhooksResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserWebhooks not implemented")
return nil, status.Error(codes.Unimplemented, "method ListUserWebhooks not implemented")
}
func (UnimplementedUserServiceServer) CreateUserWebhook(context.Context, *CreateUserWebhookRequest) (*UserWebhook, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateUserWebhook not implemented")
return nil, status.Error(codes.Unimplemented, "method CreateUserWebhook not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserWebhook(context.Context, *UpdateUserWebhookRequest) (*UserWebhook, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserWebhook not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateUserWebhook not implemented")
}
func (UnimplementedUserServiceServer) DeleteUserWebhook(context.Context, *DeleteUserWebhookRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteUserWebhook not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteUserWebhook not implemented")
}
func (UnimplementedUserServiceServer) ListUserNotifications(context.Context, *ListUserNotificationsRequest) (*ListUserNotificationsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListUserNotifications not implemented")
return nil, status.Error(codes.Unimplemented, "method ListUserNotifications not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserNotification(context.Context, *UpdateUserNotificationRequest) (*UserNotification, error) {
return nil, status.Errorf(codes.Unimplemented, "method UpdateUserNotification not implemented")
return nil, status.Error(codes.Unimplemented, "method UpdateUserNotification not implemented")
}
func (UnimplementedUserServiceServer) DeleteUserNotification(context.Context, *DeleteUserNotificationRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteUserNotification not implemented")
return nil, status.Error(codes.Unimplemented, "method DeleteUserNotification not implemented")
}
func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {}
func (UnimplementedUserServiceServer) testEmbeddedByValue() {}
@ -482,7 +482,7 @@ type UnsafeUserServiceServer interface {
}
func RegisterUserServiceServer(s grpc.ServiceRegistrar, srv UserServiceServer) {
// If the following call pancis, it indicates UnimplementedUserServiceServer was
// If the following call panics, it indicates UnimplementedUserServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@ -2198,8 +2198,6 @@ components:
type: string
logoUrl:
type: string
locale:
type: string
description: Custom profile configuration for instance branding.
GetCurrentSessionResponse:
type: object
@ -2290,11 +2288,6 @@ components:
InstanceSetting_GeneralSetting:
type: object
properties:
theme:
type: string
description: |-
theme is the name of the selected theme.
This references a CSS file in the web/public/themes/ directory.
disallowUserRegistration:
type: boolean
description: disallow_user_registration disallows user registration.

View File

@ -313,9 +313,6 @@ func (x *InstanceBasicSetting) GetSchemaVersion() string {
type InstanceGeneralSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
// theme is the name of the selected theme.
// This references a CSS file in the web/public/themes/ directory.
Theme string `protobuf:"bytes,1,opt,name=theme,proto3" json:"theme,omitempty"`
// disallow_user_registration disallows user registration.
DisallowUserRegistration bool `protobuf:"varint,2,opt,name=disallow_user_registration,json=disallowUserRegistration,proto3" json:"disallow_user_registration,omitempty"`
// disallow_password_auth disallows password authentication.
@ -368,13 +365,6 @@ func (*InstanceGeneralSetting) Descriptor() ([]byte, []int) {
return file_store_instance_setting_proto_rawDescGZIP(), []int{2}
}
func (x *InstanceGeneralSetting) GetTheme() string {
if x != nil {
return x.Theme
}
return ""
}
func (x *InstanceGeneralSetting) GetDisallowUserRegistration() bool {
if x != nil {
return x.DisallowUserRegistration
@ -436,7 +426,6 @@ type InstanceCustomProfile struct {
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
LogoUrl string `protobuf:"bytes,3,opt,name=logo_url,json=logoUrl,proto3" json:"logo_url,omitempty"`
Locale string `protobuf:"bytes,4,opt,name=locale,proto3" json:"locale,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -492,13 +481,6 @@ func (x *InstanceCustomProfile) GetLogoUrl() string {
return ""
}
func (x *InstanceCustomProfile) GetLocale() string {
if x != nil {
return x.Locale
}
return ""
}
type InstanceStorageSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
// storage_type is the storage type.
@ -771,9 +753,8 @@ const file_store_instance_setting_proto_rawDesc = "" +
"\x14InstanceBasicSetting\x12\x1d\n" +
"\n" +
"secret_key\x18\x01 \x01(\tR\tsecretKey\x12%\n" +
"\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"\xec\x03\n" +
"\x16InstanceGeneralSetting\x12\x14\n" +
"\x05theme\x18\x01 \x01(\tR\x05theme\x12<\n" +
"\x0eschema_version\x18\x02 \x01(\tR\rschemaVersion\"\xd6\x03\n" +
"\x16InstanceGeneralSetting\x12<\n" +
"\x1adisallow_user_registration\x18\x02 \x01(\bR\x18disallowUserRegistration\x124\n" +
"\x16disallow_password_auth\x18\x03 \x01(\bR\x14disallowPasswordAuth\x12+\n" +
"\x11additional_script\x18\x04 \x01(\tR\x10additionalScript\x12)\n" +
@ -781,12 +762,11 @@ const file_store_instance_setting_proto_rawDesc = "" +
"\x0ecustom_profile\x18\x06 \x01(\v2\".memos.store.InstanceCustomProfileR\rcustomProfile\x121\n" +
"\x15week_start_day_offset\x18\a \x01(\x05R\x12weekStartDayOffset\x128\n" +
"\x18disallow_change_username\x18\b \x01(\bR\x16disallowChangeUsername\x128\n" +
"\x18disallow_change_nickname\x18\t \x01(\bR\x16disallowChangeNickname\"\x82\x01\n" +
"\x18disallow_change_nickname\x18\t \x01(\bR\x16disallowChangeNickname\"j\n" +
"\x15InstanceCustomProfile\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x19\n" +
"\blogo_url\x18\x03 \x01(\tR\alogoUrl\x12\x16\n" +
"\x06locale\x18\x04 \x01(\tR\x06locale\"\xd3\x02\n" +
"\blogo_url\x18\x03 \x01(\tR\alogoUrl\"\xd3\x02\n" +
"\x16InstanceStorageSetting\x12R\n" +
"\fstorage_type\x18\x01 \x01(\x0e2/.memos.store.InstanceStorageSetting.StorageTypeR\vstorageType\x12+\n" +
"\x11filepath_template\x18\x02 \x01(\tR\x10filepathTemplate\x12/\n" +

View File

@ -34,9 +34,6 @@ message InstanceBasicSetting {
}
message InstanceGeneralSetting {
// theme is the name of the selected theme.
// This references a CSS file in the web/public/themes/ directory.
string theme = 1;
// disallow_user_registration disallows user registration.
bool disallow_user_registration = 2;
// disallow_password_auth disallows password authentication.
@ -61,7 +58,6 @@ message InstanceCustomProfile {
string title = 1;
string description = 2;
string logo_url = 3;
string locale = 4;
}
message InstanceStorageSetting {

View File

@ -154,14 +154,8 @@ func convertInstanceGeneralSettingFromStore(setting *storepb.InstanceGeneralSett
if setting == nil {
return nil
}
// Backfill theme if empty
theme := setting.Theme
if theme == "" {
theme = "default"
}
generalSetting := &v1pb.InstanceSetting_GeneralSetting{
Theme: theme,
DisallowUserRegistration: setting.DisallowUserRegistration,
DisallowPasswordAuth: setting.DisallowPasswordAuth,
AdditionalScript: setting.AdditionalScript,
@ -175,7 +169,6 @@ func convertInstanceGeneralSettingFromStore(setting *storepb.InstanceGeneralSett
Title: setting.CustomProfile.Title,
Description: setting.CustomProfile.Description,
LogoUrl: setting.CustomProfile.LogoUrl,
Locale: setting.CustomProfile.Locale,
}
}
return generalSetting
@ -186,7 +179,6 @@ func convertInstanceGeneralSettingToStore(setting *v1pb.InstanceSetting_GeneralS
return nil
}
generalSetting := &storepb.InstanceGeneralSetting{
Theme: setting.Theme,
DisallowUserRegistration: setting.DisallowUserRegistration,
DisallowPasswordAuth: setting.DisallowPasswordAuth,
AdditionalScript: setting.AdditionalScript,
@ -200,7 +192,6 @@ func convertInstanceGeneralSettingToStore(setting *v1pb.InstanceSetting_GeneralS
Title: setting.CustomProfile.Title,
Description: setting.CustomProfile.Description,
LogoUrl: setting.CustomProfile.LogoUrl,
Locale: setting.CustomProfile.Locale,
}
}
return generalSetting

View File

@ -415,15 +415,9 @@ func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error)
}
customProfile := settings.CustomProfile
// Use locale as language if available, default to en-us
language := "en-us"
if customProfile.Locale != "" {
language = customProfile.Locale
}
return RSSHeading{
Title: customProfile.Title,
Description: customProfile.Description,
Language: language,
Language: "en-us",
}, nil
}

View File

@ -5,7 +5,7 @@ import { Outlet } from "react-router-dom";
import useNavigateTo from "./hooks/useNavigateTo";
import { instanceStore, userStore } from "./store";
import { cleanupExpiredOAuthState } from "./utils/oauth";
import { loadTheme, setupSystemThemeListener } from "./utils/theme";
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "./utils/theme";
const App = observer(() => {
const { i18n } = useTranslation();
@ -54,55 +54,44 @@ const App = observer(() => {
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
}, [instanceGeneralSetting.customProfile]);
// Update HTML lang and dir attributes based on current locale
useEffect(() => {
const currentLocale = instanceStore.state.locale;
// This will trigger re-rendering of the whole app.
i18n.changeLanguage(currentLocale);
const currentLocale = i18n.language;
document.documentElement.setAttribute("lang", currentLocale);
if (["ar", "fa"].includes(currentLocale)) {
document.documentElement.setAttribute("dir", "rtl");
} else {
document.documentElement.setAttribute("dir", "ltr");
}
}, [instanceStore.state.locale]);
}, [i18n.language]);
// Apply theme when user setting changes
useEffect(() => {
if (!userGeneralSetting) {
return;
}
instanceStore.state.setPartial({
locale: userGeneralSetting.locale || instanceStore.state.locale,
theme: userGeneralSetting.theme || instanceStore.state.theme,
});
}, [userGeneralSetting?.locale, userGeneralSetting?.theme]);
// Load theme when instance theme changes or user setting changes
useEffect(() => {
const currentTheme = userGeneralSetting?.theme || instanceStore.state.theme;
if (currentTheme) {
loadTheme(currentTheme);
}
}, [userGeneralSetting?.theme, instanceStore.state.theme]);
const theme = getThemeWithFallback(userGeneralSetting.theme);
loadTheme(theme);
}, [userGeneralSetting?.theme]);
// Listen for system theme changes when using "system" theme
useEffect(() => {
const currentTheme = userGeneralSetting?.theme || instanceStore.state.theme;
const theme = getThemeWithFallback(userGeneralSetting?.theme);
// Only set up listener if theme is "system"
if (currentTheme !== "system") {
if (theme !== "system") {
return;
}
// Set up listener for OS theme preference changes
const cleanup = setupSystemThemeListener(() => {
// Reload theme when system preference changes
loadTheme(currentTheme);
loadTheme(theme);
});
// Cleanup listener on unmount or when theme changes
return cleanup;
}, [userGeneralSetting?.theme, instanceStore.state.theme]);
}, [userGeneralSetting?.theme]);
return <Outlet />;
});

View File

@ -1,6 +1,8 @@
import { observer } from "mobx-react-lite";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import { getInitialTheme, loadTheme } from "@/utils/theme";
import LocaleSelect from "./LocaleSelect";
import ThemeSelect from "./ThemeSelect";
@ -9,10 +11,22 @@ interface Props {
}
const AuthFooter = observer(({ className }: Props) => {
const { i18n: i18nInstance } = useTranslation();
const currentLocale = i18nInstance.language as Locale;
const currentTheme = getInitialTheme();
const handleLocaleChange = (locale: Locale) => {
i18n.changeLanguage(locale);
};
const handleThemeChange = (theme: string) => {
loadTheme(theme);
};
return (
<div className={cn("mt-4 flex flex-row items-center justify-center w-full gap-2", className)}>
<LocaleSelect value={instanceStore.state.locale} onChange={(locale) => instanceStore.state.setPartial({ locale })} />
<ThemeSelect value={instanceStore.state.theme} onValueChange={(theme) => instanceStore.state.setPartial({ theme })} />
<LocaleSelect value={currentLocale} onChange={handleLocaleChange} />
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
</div>
);
});

View File

@ -4,7 +4,8 @@ import { CheckIcon, CopyIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import { userStore } from "@/store";
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
import { MermaidBlock } from "./MermaidBlock";
interface PreProps {
@ -93,8 +94,9 @@ export const CodeBlock = observer(({ children, className, ...props }: PreProps)
);
}
const appTheme = instanceStore.state.theme;
const isDarkTheme = appTheme.includes("dark");
const theme = getThemeWithFallback(userStore.state.userGeneralSetting?.theme);
const resolvedTheme = resolveTheme(theme);
const isDarkTheme = resolvedTheme.includes("dark");
// Dynamically load highlight.js theme based on app theme
useEffect(() => {
@ -121,7 +123,7 @@ export const CodeBlock = observer(({ children, className, ...props }: PreProps)
};
dynamicImportStyle();
}, [appTheme, isDarkTheme]);
}, [resolvedTheme, isDarkTheme]);
// Highlight code using highlight.js
const highlightedCode = useMemo(() => {

View File

@ -2,8 +2,8 @@ import mermaid from "mermaid";
import { observer } from "mobx-react-lite";
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { instanceStore, userStore } from "@/store";
import { resolveTheme, setupSystemThemeListener } from "@/utils/theme";
import { userStore } from "@/store";
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
interface MermaidBlockProps {
children?: React.ReactNode;
@ -25,7 +25,8 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps
const codeContent = String(codeElement?.props?.children || "").replace(/\n$/, "");
// Get theme preference (reactive via MobX observer)
const themePreference = userStore.state.userGeneralSetting?.theme || instanceStore.state.theme;
// Falls back to localStorage or system preference if no user setting
const themePreference = getThemeWithFallback(userStore.state.userGeneralSetting?.theme);
// Resolve theme to actual value (handles "system" theme + system theme changes)
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);

View File

@ -13,7 +13,6 @@ import { instanceSettingNamePrefix } from "@/store/common";
import { IdentityProvider } from "@/types/proto/api/v1/idp_service";
import { InstanceSetting_GeneralSetting, InstanceSetting_Key } from "@/types/proto/api/v1/instance_service";
import { useTranslate } from "@/utils/i18n";
import ThemeSelect from "../ThemeSelect";
import UpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
import SettingGroup from "./SettingGroup";
import SettingRow from "./SettingRow";
@ -79,14 +78,6 @@ const InstanceSection = observer(() => {
</SettingGroup>
<SettingGroup title={t("setting.system-section.title")} showSeparator>
<SettingRow label="Theme">
<ThemeSelect
value={instanceGeneralSetting.theme || "default"}
onValueChange={(value: string) => updatePartialSetting({ theme: value })}
className="min-w-fit"
/>
</SettingRow>
<SettingRow label={t("setting.system-section.additional-style")} vertical>
<Textarea
className="font-mono w-full"

View File

@ -1,10 +1,12 @@
import { observer } from "mobx-react-lite";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { instanceStore, userStore } from "@/store";
import i18n from "@/i18n";
import { userStore } from "@/store";
import { Visibility } from "@/types/proto/api/v1/memo_service";
import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
import { loadTheme } from "@/utils/theme";
import LocaleSelect from "../LocaleSelect";
import ThemeSelect from "../ThemeSelect";
import VisibilityIcon from "../VisibilityIcon";
@ -18,8 +20,8 @@ const PreferencesSection = observer(() => {
const generalSetting = userStore.state.userGeneralSetting;
const handleLocaleSelectChange = async (locale: Locale) => {
// Update instance store immediately for instant UI feedback
instanceStore.state.setPartial({ locale });
// Apply locale immediately for instant UI feedback
i18n.changeLanguage(locale);
// Persist to user settings
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
};
@ -29,8 +31,8 @@ const PreferencesSection = observer(() => {
};
const handleThemeChange = async (theme: string) => {
// Update instance store immediately for instant UI feedback
instanceStore.state.setPartial({ theme });
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
};

View File

@ -1,6 +1,5 @@
import { Monitor, Moon, MoonStar, Palette, Sun, Wallpaper } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { instanceStore } from "@/store";
import { THEME_OPTIONS } from "@/utils/theme";
interface ThemeSelectProps {
@ -19,13 +18,11 @@ const THEME_ICONS: Record<string, JSX.Element> = {
};
const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) => {
const currentTheme = value || instanceStore.state.theme || "system";
const currentTheme = value || "system";
const handleThemeChange = (newTheme: Theme) => {
const handleThemeChange = (newTheme: string) => {
if (onValueChange) {
onValueChange(newTheme);
} else {
instanceStore.setTheme(newTheme);
}
};

View File

@ -9,8 +9,6 @@ import { instanceStore } from "@/store";
import { instanceSettingNamePrefix } from "@/store/common";
import { InstanceSetting_GeneralSetting_CustomProfile, InstanceSetting_Key } from "@/types/proto/api/v1/instance_service";
import { useTranslate } from "@/utils/i18n";
import LocaleSelect from "./LocaleSelect";
import ThemeSelect from "./ThemeSelect";
interface Props {
open: boolean;
@ -52,18 +50,11 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props)
});
};
const handleLocaleSelectChange = (locale: Locale) => {
setPartialState({
locale: locale,
});
};
const handleRestoreButtonClick = () => {
setPartialState({
title: "Memos",
logoUrl: "/logo.webp",
description: "",
locale: "en",
});
};
@ -126,16 +117,6 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props)
placeholder="Enter description"
/>
</div>
<div className="grid gap-2">
<Label>{t("setting.system-section.customize-server.locale")}</Label>
<LocaleSelect value={customProfile.locale} onChange={handleLocaleSelectChange} />
</div>
<div className="grid gap-2">
<Label>Theme</Label>
<ThemeSelect />
</div>
</div>
<DialogFooter className="flex-col sm:flex-row sm:justify-between gap-2">

View File

@ -3,12 +3,12 @@ import { observer } from "mobx-react-lite";
import { authServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
import { locales } from "@/i18n";
import i18n, { locales } from "@/i18n";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { instanceStore, userStore } from "@/store";
import { userStore } from "@/store";
import { getLocaleDisplayName, useTranslate } from "@/utils/i18n";
import { THEME_OPTIONS } from "@/utils/theme";
import { loadTheme, THEME_OPTIONS } from "@/utils/theme";
import UserAvatar from "./UserAvatar";
import {
DropdownMenu,
@ -34,13 +34,16 @@ const UserMenu = observer((props: Props) => {
const currentTheme = generalSetting?.theme || "default";
const handleLocaleChange = async (locale: Locale) => {
// Update instance store immediately for instant UI feedback
instanceStore.state.setPartial({ locale });
// Apply locale immediately for instant UI feedback
i18n.changeLanguage(locale);
// Persist to user settings
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
};
const handleThemeChange = async (theme: string) => {
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
};

View File

@ -9,11 +9,12 @@ import router from "./router";
// Configure MobX before importing any stores
import "./store/config";
import { initialInstanceStore } from "./store/instance";
import { initialUserStore } from "./store/user";
import userStore, { initialUserStore } from "./store/user";
import { applyThemeEarly } from "./utils/theme";
import "leaflet/dist/leaflet.css";
// Apply theme early to prevent flash of wrong theme
// This uses localStorage as the source before user settings are loaded
applyThemeEarly();
const Main = observer(() => (
@ -24,9 +25,14 @@ const Main = observer(() => (
));
(async () => {
// Initialize stores
await initialInstanceStore();
await initialUserStore();
// Apply user preferences (theme & locale) after user settings are loaded
// This will override the early theme with user's actual preference
userStore.applyUserPreferences();
const container = document.getElementById("root");
const root = createRoot(container as HTMLElement);
root.render(<Main />);

View File

@ -22,9 +22,6 @@ export {
memoNamePrefix,
userNamePrefix,
} from "./common";
// Re-export instance types
export type { Theme } from "./instance";
export { isValidTheme } from "./instance";
// Re-export filter types
export type { FilterFactor, MemoFilter } from "./memoFilter";
export { getMemoFilterKey, parseFilterQuery, stringifyFilters } from "./memoFilter";

View File

@ -9,21 +9,11 @@ import {
InstanceSetting_Key,
InstanceSetting_MemoRelatedSetting,
} from "@/types/proto/api/v1/instance_service";
import { isValidateLocale } from "@/utils/i18n";
import { createServerStore, StandardState } from "./base-store";
import { instanceSettingNamePrefix } from "./common";
import { createRequestKey } from "./store-utils";
const VALID_THEMES = ["system", "default", "default-dark", "midnight", "paper", "whitewall"] as const;
export type Theme = (typeof VALID_THEMES)[number];
export function isValidTheme(theme: string): theme is Theme {
return VALID_THEMES.includes(theme as Theme);
}
class InstanceState extends StandardState {
locale: string = "en";
theme: Theme | string = "system";
profile: InstanceProfile = InstanceProfile.fromPartial({});
settings: InstanceSetting[] = [];
@ -42,29 +32,6 @@ class InstanceState extends StandardState {
return setting?.memoRelatedSetting || InstanceSetting_MemoRelatedSetting.fromPartial({});
}).get();
}
setPartial(partial: Partial<InstanceState>): void {
const finalState = { ...this, ...partial };
// Validate locale
if (partial.locale !== undefined && !isValidateLocale(finalState.locale)) {
console.warn(`Invalid locale "${finalState.locale}", falling back to "en"`);
finalState.locale = "en";
}
// Validate theme - accept string and validate
if (partial.theme !== undefined) {
const themeStr = String(finalState.theme);
if (!isValidTheme(themeStr)) {
console.warn(`Invalid theme "${themeStr}", falling back to "default"`);
finalState.theme = "default";
} else {
finalState.theme = themeStr;
}
}
Object.assign(this, finalState);
}
}
const instanceStore = (() => {
@ -114,33 +81,6 @@ const instanceStore = (() => {
return setting || InstanceSetting.fromPartial({});
};
const setTheme = async (theme: string): Promise<void> => {
// Validate theme
if (!isValidTheme(theme)) {
console.warn(`Invalid theme "${theme}", ignoring`);
return;
}
// Update local state immediately
state.setPartial({ theme });
// Persist to server
const generalSetting = state.generalSetting;
const updatedGeneralSetting = InstanceSetting_GeneralSetting.fromPartial({
...generalSetting,
customProfile: {
...generalSetting.customProfile,
},
});
await upsertInstanceSetting(
InstanceSetting.fromPartial({
name: `${instanceSettingNamePrefix}${InstanceSetting_Key.GENERAL}`,
generalSetting: updatedGeneralSetting,
}),
);
};
const fetchInstanceProfile = async (): Promise<InstanceProfile> => {
const requestKey = createRequestKey("fetchInstanceProfile");
@ -161,7 +101,6 @@ const instanceStore = (() => {
fetchInstanceProfile,
upsertInstanceSetting,
getInstanceSettingByKey,
setTheme,
};
})();
@ -178,19 +117,9 @@ export const initialInstanceStore = async (): Promise<void> => {
]);
// Apply settings to state
const instanceGeneralSetting = instanceStore.state.generalSetting;
instanceStore.state.setPartial({
locale: instanceGeneralSetting.customProfile?.locale || "en",
theme: instanceGeneralSetting.theme || "system",
profile: instanceProfile,
});
Object.assign(instanceStore.state, { profile: instanceProfile });
} catch (error) {
console.error("Failed to initialize instance store:", error);
// Set default fallback values
instanceStore.state.setPartial({
locale: "en",
theme: "system",
});
}
};

View File

@ -1,6 +1,7 @@
import { uniqueId } from "lodash-es";
import { computed, makeAutoObservable } from "mobx";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/grpcweb";
import i18n from "@/i18n";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
import {
User,
@ -13,8 +14,8 @@ import {
UserSetting_WebhooksSetting,
UserStats,
} from "@/types/proto/api/v1/user_service";
import { findNearestMatchedLanguage } from "@/utils/i18n";
import instanceStore from "./instance";
import { getLocaleWithFallback } from "@/utils/i18n";
import { getThemeWithFallback, loadTheme } from "@/utils/theme";
import { createRequestKey, RequestDeduplicator, StoreError } from "./store-utils";
class LocalState {
@ -283,6 +284,20 @@ const userStore = (() => {
state.statsStateId = id;
};
// Applies user preferences (theme and locale) with proper fallbacks
// This should be called after user settings are loaded
const applyUserPreferences = () => {
const generalSetting = state.userGeneralSetting;
// Apply theme with fallback: user setting -> localStorage -> system
const theme = getThemeWithFallback(generalSetting?.theme);
loadTheme(theme);
// Apply locale with fallback: user setting -> browser language
const locale = getLocaleWithFallback(generalSetting?.locale);
i18n.changeLanguage(locale);
};
return {
state,
getOrFetchUserByName,
@ -299,6 +314,7 @@ const userStore = (() => {
deleteNotification,
fetchUserStats,
setStatsStateId,
applyUserPreferences,
};
})();
@ -306,22 +322,18 @@ const userStore = (() => {
// 1. Fetch current authenticated user session
// 2. Set current user in store (required for subsequent calls)
// 3. Fetch user settings (depends on currentUser being set)
// 4. Apply user preferences to instance store
export const initialUserStore = async () => {
try {
// Step 1: Authenticate and get current user
const { user: currentUser } = await authServiceClient.getCurrentSession({});
if (!currentUser) {
// No authenticated user - clear state and use default locale
// No authenticated user - clear state
userStore.state.setPartial({
currentUser: undefined,
userGeneralSetting: undefined,
userMapByName: {},
});
const locale = findNearestMatchedLanguage(navigator.language);
instanceStore.state.setPartial({ locale });
return;
}
@ -339,27 +351,8 @@ export const initialUserStore = async () => {
// CRITICAL: This must happen after currentUser is set in step 2
// The fetchUserSettings() and fetchUserStats() methods check state.currentUser internally
await Promise.all([userStore.fetchUserSettings(), userStore.fetchUserStats()]);
// Step 4: Apply user preferences to instance
// CRITICAL: This must happen after fetchUserSettings() completes
// We need userGeneralSetting to be populated before accessing it
const generalSetting = userStore.state.userGeneralSetting;
if (generalSetting) {
// Note: setPartial will validate theme automatically
instanceStore.state.setPartial({
locale: generalSetting.locale,
theme: generalSetting.theme || "default", // Validation handled by setPartial
});
} else {
// Fallback if settings weren't loaded
const locale = findNearestMatchedLanguage(navigator.language);
instanceStore.state.setPartial({ locale });
}
} catch (error) {
// On any error, fall back to browser language detection
console.error("Failed to initialize user store:", error);
const locale = findNearestMatchedLanguage(navigator.language);
instanceStore.state.setPartial({ locale });
}
};

View File

@ -92,11 +92,6 @@ export function instanceSetting_KeyToNumber(object: InstanceSetting_Key): number
/** General instance settings configuration. */
export interface InstanceSetting_GeneralSetting {
/**
* theme is the name of the selected theme.
* This references a CSS file in the web/public/themes/ directory.
*/
theme: string;
/** disallow_user_registration disallows user registration. */
disallowUserRegistration: boolean;
/** disallow_password_auth disallows password authentication. */
@ -126,7 +121,6 @@ export interface InstanceSetting_GeneralSetting_CustomProfile {
title: string;
description: string;
logoUrl: string;
locale: string;
}
/** Storage configuration settings for instance attachments. */
@ -453,7 +447,6 @@ export const InstanceSetting: MessageFns<InstanceSetting> = {
function createBaseInstanceSetting_GeneralSetting(): InstanceSetting_GeneralSetting {
return {
theme: "",
disallowUserRegistration: false,
disallowPasswordAuth: false,
additionalScript: "",
@ -467,9 +460,6 @@ function createBaseInstanceSetting_GeneralSetting(): InstanceSetting_GeneralSett
export const InstanceSetting_GeneralSetting: MessageFns<InstanceSetting_GeneralSetting> = {
encode(message: InstanceSetting_GeneralSetting, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.theme !== "") {
writer.uint32(10).string(message.theme);
}
if (message.disallowUserRegistration !== false) {
writer.uint32(16).bool(message.disallowUserRegistration);
}
@ -504,14 +494,6 @@ export const InstanceSetting_GeneralSetting: MessageFns<InstanceSetting_GeneralS
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.theme = reader.string();
continue;
}
case 2: {
if (tag !== 16) {
break;
@ -590,7 +572,6 @@ export const InstanceSetting_GeneralSetting: MessageFns<InstanceSetting_GeneralS
},
fromPartial(object: DeepPartial<InstanceSetting_GeneralSetting>): InstanceSetting_GeneralSetting {
const message = createBaseInstanceSetting_GeneralSetting();
message.theme = object.theme ?? "";
message.disallowUserRegistration = object.disallowUserRegistration ?? false;
message.disallowPasswordAuth = object.disallowPasswordAuth ?? false;
message.additionalScript = object.additionalScript ?? "";
@ -606,7 +587,7 @@ export const InstanceSetting_GeneralSetting: MessageFns<InstanceSetting_GeneralS
};
function createBaseInstanceSetting_GeneralSetting_CustomProfile(): InstanceSetting_GeneralSetting_CustomProfile {
return { title: "", description: "", logoUrl: "", locale: "" };
return { title: "", description: "", logoUrl: "" };
}
export const InstanceSetting_GeneralSetting_CustomProfile: MessageFns<InstanceSetting_GeneralSetting_CustomProfile> = {
@ -623,9 +604,6 @@ export const InstanceSetting_GeneralSetting_CustomProfile: MessageFns<InstanceSe
if (message.logoUrl !== "") {
writer.uint32(26).string(message.logoUrl);
}
if (message.locale !== "") {
writer.uint32(34).string(message.locale);
}
return writer;
},
@ -660,14 +638,6 @@ export const InstanceSetting_GeneralSetting_CustomProfile: MessageFns<InstanceSe
message.logoUrl = reader.string();
continue;
}
case 4: {
if (tag !== 34) {
break;
}
message.locale = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@ -689,7 +659,6 @@ export const InstanceSetting_GeneralSetting_CustomProfile: MessageFns<InstanceSe
message.title = object.title ?? "";
message.description = object.description ?? "";
message.logoUrl = object.logoUrl ?? "";
message.locale = object.locale ?? "";
return message;
},
};

View File

@ -52,6 +52,19 @@ export const isValidateLocale = (locale: string | undefined | null): boolean =>
return locales.includes(locale);
};
// Gets the locale to use with proper priority:
// 1. User setting (if logged in and has preference)
// 2. Browser language preference
export const getLocaleWithFallback = (userLocale?: string): Locale => {
// Priority 1: User setting (if logged in and valid)
if (userLocale && isValidateLocale(userLocale)) {
return userLocale as Locale;
}
// Priority 2: Browser language
return findNearestMatchedLanguage(navigator.language);
};
// Get the display name for a locale in its native language
export const getLocaleDisplayName = (locale: string): string => {
try {

View File

@ -3,11 +3,24 @@ import midnightThemeContent from "../themes/midnight.css?raw";
import paperThemeContent from "../themes/paper.css?raw";
import whitewallThemeContent from "../themes/whitewall.css?raw";
const VALID_THEMES = ["system", "default", "default-dark", "midnight", "paper", "whitewall"] as const;
type ValidTheme = (typeof VALID_THEMES)[number];
// ============================================================================
// Types and Constants
// ============================================================================
const THEME_CONTENT: Record<ValidTheme, string | null> = {
system: null, // System theme dynamically chooses between default and default-dark
const VALID_THEMES = ["system", "default", "default-dark", "midnight", "paper", "whitewall"] as const;
export type Theme = (typeof VALID_THEMES)[number];
export type ResolvedTheme = Exclude<Theme, "system">;
export interface ThemeOption {
value: string;
label: string;
}
const STORAGE_KEY = "memos-theme";
const STYLE_ELEMENT_ID = "instance-theme";
const THEME_CONTENT: Record<ResolvedTheme, string | null> = {
default: null,
"default-dark": defaultDarkThemeContent,
midnight: midnightThemeContent,
@ -15,11 +28,6 @@ const THEME_CONTENT: Record<ValidTheme, string | null> = {
whitewall: whitewallThemeContent,
};
export interface ThemeOption {
value: string;
label: string;
}
export const THEME_OPTIONS: ThemeOption[] = [
{ value: "system", label: "Sync with system" },
{ value: "default", label: "Light" },
@ -29,102 +37,201 @@ export const THEME_OPTIONS: ThemeOption[] = [
{ value: "whitewall", label: "Whitewall" },
];
const validateTheme = (theme: string): ValidTheme => {
return VALID_THEMES.includes(theme as ValidTheme) ? (theme as ValidTheme) : "default";
// ============================================================================
// Theme Validation and Detection
// ============================================================================
/**
* Validates and normalizes a theme string to a valid theme.
* Falls back to "default" for invalid themes.
*/
const validateTheme = (theme: string): Theme => {
return VALID_THEMES.includes(theme as Theme) ? (theme as Theme) : "default";
};
export const getSystemTheme = (): "default" | "default-dark" => {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "default-dark" : "default";
/**
* Detects the system's preferred color scheme.
* @returns "default-dark" for dark mode, "default" for light mode
*/
export const getSystemTheme = (): ResolvedTheme => {
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
return "default-dark";
}
return "default";
};
// Resolves "system" to actual theme based on OS preference
export const resolveTheme = (theme: string): "default" | "default-dark" | "midnight" | "paper" | "whitewall" => {
if (theme === "system") {
return getSystemTheme();
}
/**
* Resolves "system" theme to the actual theme based on OS preference.
* Other themes are returned as-is after validation.
*/
export const resolveTheme = (theme: string): ResolvedTheme => {
const validTheme = validateTheme(theme);
return validTheme === "system" ? getSystemTheme() : validTheme;
};
// Gets the theme that should be applied on initial load
export const getInitialTheme = (): ValidTheme => {
// Try to get stored theme from localStorage
// ============================================================================
// LocalStorage Helpers
// ============================================================================
/**
* Safely reads the theme from localStorage.
* @returns The stored theme, or null if not found or unavailable
*/
const getStoredTheme = (): Theme | null => {
try {
const storedTheme = localStorage.getItem("memos-theme");
if (storedTheme && VALID_THEMES.includes(storedTheme as ValidTheme)) {
return storedTheme as ValidTheme;
}
const stored = localStorage.getItem(STORAGE_KEY);
return stored && VALID_THEMES.includes(stored as Theme) ? (stored as Theme) : null;
} catch {
// localStorage might not be available
return null;
}
};
/**
* Safely stores the theme to localStorage.
*/
const setStoredTheme = (theme: Theme): void => {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
// localStorage might not be available (SSR, private browsing, etc.)
}
};
// ============================================================================
// Theme Selection with Fallbacks
// ============================================================================
/**
* Gets the theme for initial page load (before user settings are available).
* Priority: localStorage -> system preference
*/
export const getInitialTheme = (): Theme => {
return getStoredTheme() ?? "system";
};
/**
* Gets the theme with full fallback chain.
* Priority:
* 1. User setting (if logged in and has preference)
* 2. localStorage (from previous session)
* 3. System preference
*/
export const getThemeWithFallback = (userTheme?: string): Theme => {
// Priority 1: User setting
if (userTheme && VALID_THEMES.includes(userTheme as Theme)) {
return userTheme as Theme;
}
// Priority 2: localStorage
const stored = getStoredTheme();
if (stored) {
return stored;
}
// Priority 3: System preference
return "system";
};
// Applies the theme early to prevent flash of wrong theme
// ============================================================================
// DOM Manipulation
// ============================================================================
/**
* Removes the existing theme style element from the DOM.
*/
const removeThemeStyle = (): void => {
document.getElementById(STYLE_ELEMENT_ID)?.remove();
};
/**
* Injects theme CSS into the document head.
* Skips injection for the default theme (uses base CSS).
*/
const injectThemeStyle = (theme: ResolvedTheme): void => {
removeThemeStyle();
if (theme === "default") {
return; // Use base CSS for default theme
}
const css = THEME_CONTENT[theme];
if (css) {
const style = document.createElement("style");
style.id = STYLE_ELEMENT_ID;
style.textContent = css;
document.head.appendChild(style);
}
};
/**
* Sets the data-theme attribute on the document element.
* This allows CSS to react to the current theme.
*/
const setThemeAttribute = (theme: ResolvedTheme): void => {
document.documentElement.setAttribute("data-theme", theme);
};
// ============================================================================
// Main Theme Loading
// ============================================================================
/**
* Loads and applies a theme.
* This function:
* 1. Validates the theme
* 2. Resolves "system" to actual theme
* 3. Injects theme CSS
* 4. Sets data-theme attribute
* 5. Persists to localStorage
*/
export const loadTheme = (themeName: string): void => {
const validTheme = validateTheme(themeName);
const resolvedTheme = resolveTheme(validTheme);
injectThemeStyle(resolvedTheme);
setThemeAttribute(resolvedTheme);
setStoredTheme(validTheme); // Store original theme preference (not resolved)
};
/**
* Applies theme early during initial page load to prevent FOUC.
* Uses only localStorage and system preference (no user settings yet).
*/
export const applyThemeEarly = (): void => {
const theme = getInitialTheme();
loadTheme(theme);
};
export const loadTheme = (themeName: string): void => {
const validTheme = validateTheme(themeName);
// ============================================================================
// System Theme Listener
// ============================================================================
// Resolve "system" to actual theme based on OS preference
const resolvedTheme = resolveTheme(validTheme);
// Remove existing theme
document.getElementById("instance-theme")?.remove();
// Apply theme (skip for default)
if (resolvedTheme !== "default") {
const css = THEME_CONTENT[resolvedTheme];
if (css) {
const style = document.createElement("style");
style.id = "instance-theme";
style.textContent = css;
document.head.appendChild(style);
}
}
// Set data attribute with resolved theme
document.documentElement.setAttribute("data-theme", resolvedTheme);
// Store theme preference (original, not resolved) for future loads
try {
localStorage.setItem("memos-theme", validTheme);
} catch {
// localStorage might not be available
}
};
// Sets up a listener for system theme preference changes
/**
* Sets up a listener for OS-level theme preference changes.
* Supports both modern (addEventListener) and legacy (addListener) APIs.
*
* @param onThemeChange - Callback invoked when system theme changes
* @returns Cleanup function to remove the listener
*/
export const setupSystemThemeListener = (onThemeChange: () => void): (() => void) => {
// Guard against SSR
if (typeof window === "undefined" || !window.matchMedia) {
return () => {}; // No-op cleanup
return () => {};
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
// Handle theme change
const handleChange = () => {
onThemeChange();
};
// Modern API (addEventListener)
// Modern API (preferred)
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
mediaQuery.addEventListener("change", onThemeChange);
return () => mediaQuery.removeEventListener("change", onThemeChange);
}
// Legacy API (addListener) - for older browsers
// Legacy API (Safari < 14)
if (mediaQuery.addListener) {
mediaQuery.addListener(handleChange);
return () => mediaQuery.removeListener(handleChange);
mediaQuery.addListener(onThemeChange);
return () => mediaQuery.removeListener(onThemeChange);
}
return () => {}; // No-op cleanup
return () => {};
};