Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
7d4d1e85
Unverified
Commit
7d4d1e85
authored
Nov 07, 2025
by
boojack
Committed by
GitHub
Nov 07, 2025
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(web): standardize theme system with auto sync option (#5231)
Co-authored-by:
Claude
<
noreply@anthropic.com
>
parent
8f29db2f
Changes
6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
90 additions
and
19 deletions
+90
-19
App.tsx
web/src/App.tsx
+20
-1
PreferencesSection.tsx
web/src/components/Settings/PreferencesSection.tsx
+4
-1
ThemeSelect.tsx
web/src/components/ThemeSelect.tsx
+3
-2
instance.ts
web/src/store/instance.ts
+4
-4
setting.d.ts
web/src/types/modules/setting.d.ts
+1
-1
theme.ts
web/src/utils/theme.ts
+58
-10
No files found.
web/src/App.tsx
View file @
7d4d1e85
...
@@ -5,7 +5,7 @@ import { Outlet } from "react-router-dom";
...
@@ -5,7 +5,7 @@ import { Outlet } from "react-router-dom";
import
useNavigateTo
from
"./hooks/useNavigateTo"
;
import
useNavigateTo
from
"./hooks/useNavigateTo"
;
import
{
userStore
,
instanceStore
}
from
"./store"
;
import
{
userStore
,
instanceStore
}
from
"./store"
;
import
{
cleanupExpiredOAuthState
}
from
"./utils/oauth"
;
import
{
cleanupExpiredOAuthState
}
from
"./utils/oauth"
;
import
{
loadTheme
}
from
"./utils/theme"
;
import
{
loadTheme
,
setupSystemThemeListener
}
from
"./utils/theme"
;
const
App
=
observer
(()
=>
{
const
App
=
observer
(()
=>
{
const
{
i18n
}
=
useTranslation
();
const
{
i18n
}
=
useTranslation
();
...
@@ -85,6 +85,25 @@ const App = observer(() => {
...
@@ -85,6 +85,25 @@ const App = observer(() => {
}
}
},
[
userGeneralSetting
?.
theme
,
instanceStore
.
state
.
theme
]);
},
[
userGeneralSetting
?.
theme
,
instanceStore
.
state
.
theme
]);
// Listen for system theme changes when using "system" theme
useEffect
(()
=>
{
const
currentTheme
=
userGeneralSetting
?.
theme
||
instanceStore
.
state
.
theme
;
// Only set up listener if theme is "system"
if
(
currentTheme
!==
"system"
)
{
return
;
}
// Set up listener for OS theme preference changes
const
cleanup
=
setupSystemThemeListener
(()
=>
{
// Reload theme when system preference changes
loadTheme
(
currentTheme
);
});
// Cleanup listener on unmount or when theme changes
return
cleanup
;
},
[
userGeneralSetting
?.
theme
,
instanceStore
.
state
.
theme
]);
return
<
Outlet
/>;
return
<
Outlet
/>;
});
});
...
...
web/src/components/Settings/PreferencesSection.tsx
View file @
7d4d1e85
...
@@ -27,6 +27,9 @@ const PreferencesSection = observer(() => {
...
@@ -27,6 +27,9 @@ const PreferencesSection = observer(() => {
};
};
const
handleThemeChange
=
async
(
theme
:
string
)
=>
{
const
handleThemeChange
=
async
(
theme
:
string
)
=>
{
// Update instance store immediately for instant UI feedback
instanceStore
.
state
.
setPartial
({
theme
});
// Persist to user settings
await
userStore
.
updateUserGeneralSetting
({
theme
},
[
"theme"
]);
await
userStore
.
updateUserGeneralSetting
({
theme
},
[
"theme"
]);
};
};
...
@@ -34,7 +37,7 @@ const PreferencesSection = observer(() => {
...
@@ -34,7 +37,7 @@ const PreferencesSection = observer(() => {
const
setting
:
UserSetting_GeneralSetting
=
generalSetting
||
{
const
setting
:
UserSetting_GeneralSetting
=
generalSetting
||
{
locale
:
"en"
,
locale
:
"en"
,
memoVisibility
:
"PRIVATE"
,
memoVisibility
:
"PRIVATE"
,
theme
:
"
default
"
,
theme
:
"
system
"
,
};
};
return
(
return
(
...
...
web/src/components/ThemeSelect.tsx
View file @
7d4d1e85
import
{
Moon
,
Palette
,
Sun
,
Wallpaper
}
from
"lucide-react"
;
import
{
Moon
,
Monitor
,
Palette
,
Sun
,
Wallpaper
}
from
"lucide-react"
;
import
{
Select
,
SelectContent
,
SelectItem
,
SelectTrigger
,
SelectValue
}
from
"@/components/ui/select"
;
import
{
Select
,
SelectContent
,
SelectItem
,
SelectTrigger
,
SelectValue
}
from
"@/components/ui/select"
;
import
{
instanceStore
}
from
"@/store"
;
import
{
instanceStore
}
from
"@/store"
;
import
{
THEME_OPTIONS
}
from
"@/utils/theme"
;
import
{
THEME_OPTIONS
}
from
"@/utils/theme"
;
...
@@ -10,6 +10,7 @@ interface ThemeSelectProps {
...
@@ -10,6 +10,7 @@ interface ThemeSelectProps {
}
}
const
THEME_ICONS
:
Record
<
string
,
JSX
.
Element
>
=
{
const
THEME_ICONS
:
Record
<
string
,
JSX
.
Element
>
=
{
system
:
<
Monitor
className=
"w-4 h-4"
/>,
default
:
<
Sun
className=
"w-4 h-4"
/>,
default
:
<
Sun
className=
"w-4 h-4"
/>,
"default-dark"
:
<
Moon
className
=
"w-4 h-4"
/>
,
"default-dark"
:
<
Moon
className
=
"w-4 h-4"
/>
,
paper
:
<
Palette
className=
"w-4 h-4"
/>,
paper
:
<
Palette
className=
"w-4 h-4"
/>,
...
@@ -17,7 +18,7 @@ const THEME_ICONS: Record<string, JSX.Element> = {
...
@@ -17,7 +18,7 @@ const THEME_ICONS: Record<string, JSX.Element> = {
};
};
const
ThemeSelect
=
({
value
,
onValueChange
,
className
}:
ThemeSelectProps
=
{})
=>
{
const
ThemeSelect
=
({
value
,
onValueChange
,
className
}:
ThemeSelectProps
=
{})
=>
{
const
currentTheme
=
value
||
instanceStore
.
state
.
theme
||
"
default
"
;
const
currentTheme
=
value
||
instanceStore
.
state
.
theme
||
"
system
"
;
const
handleThemeChange
=
(
newTheme
:
Theme
)
=>
{
const
handleThemeChange
=
(
newTheme
:
Theme
)
=>
{
if
(
onValueChange
)
{
if
(
onValueChange
)
{
...
...
web/src/store/instance.ts
View file @
7d4d1e85
...
@@ -17,7 +17,7 @@ import { createRequestKey } from "./store-utils";
...
@@ -17,7 +17,7 @@ import { createRequestKey } from "./store-utils";
/**
/**
* Valid theme options
* Valid theme options
*/
*/
const
VALID_THEMES
=
[
"default"
,
"default-dark"
,
"paper"
,
"whitewall"
]
as
const
;
const
VALID_THEMES
=
[
"
system"
,
"
default"
,
"default-dark"
,
"paper"
,
"whitewall"
]
as
const
;
export
type
Theme
=
(
typeof
VALID_THEMES
)[
number
];
export
type
Theme
=
(
typeof
VALID_THEMES
)[
number
];
/**
/**
...
@@ -40,7 +40,7 @@ class InstanceState extends StandardState {
...
@@ -40,7 +40,7 @@ class InstanceState extends StandardState {
* Current theme
* Current theme
* Note: Accepts string for flexibility, but validates to Theme
* Note: Accepts string for flexibility, but validates to Theme
*/
*/
theme
:
Theme
|
string
=
"
default
"
;
theme
:
Theme
|
string
=
"
system
"
;
/**
/**
* Instance profile containing owner and metadata
* Instance profile containing owner and metadata
...
@@ -249,7 +249,7 @@ export const initialInstanceStore = async (): Promise<void> => {
...
@@ -249,7 +249,7 @@ export const initialInstanceStore = async (): Promise<void> => {
const
instanceGeneralSetting
=
instanceStore
.
state
.
generalSetting
;
const
instanceGeneralSetting
=
instanceStore
.
state
.
generalSetting
;
instanceStore
.
state
.
setPartial
({
instanceStore
.
state
.
setPartial
({
locale
:
instanceGeneralSetting
.
customProfile
?.
locale
||
"en"
,
locale
:
instanceGeneralSetting
.
customProfile
?.
locale
||
"en"
,
theme
:
"default
"
,
theme
:
instanceGeneralSetting
.
theme
||
"system
"
,
profile
:
instanceProfile
,
profile
:
instanceProfile
,
});
});
}
catch
(
error
)
{
}
catch
(
error
)
{
...
@@ -257,7 +257,7 @@ export const initialInstanceStore = async (): Promise<void> => {
...
@@ -257,7 +257,7 @@ export const initialInstanceStore = async (): Promise<void> => {
// Set default fallback values
// Set default fallback values
instanceStore
.
state
.
setPartial
({
instanceStore
.
state
.
setPartial
({
locale
:
"en"
,
locale
:
"en"
,
theme
:
"
default
"
,
theme
:
"
system
"
,
});
});
}
}
};
};
...
...
web/src/types/modules/setting.d.ts
View file @
7d4d1e85
type
Theme
=
"default"
|
"default-dark"
|
"paper"
|
"whitewall"
;
type
Theme
=
"
system"
|
"
default"
|
"default-dark"
|
"paper"
|
"whitewall"
;
web/src/utils/theme.ts
View file @
7d4d1e85
...
@@ -2,10 +2,11 @@ import defaultDarkThemeContent from "../themes/default-dark.css?raw";
...
@@ -2,10 +2,11 @@ import defaultDarkThemeContent from "../themes/default-dark.css?raw";
import
paperThemeContent
from
"../themes/paper.css?raw"
;
import
paperThemeContent
from
"../themes/paper.css?raw"
;
import
whitewallThemeContent
from
"../themes/whitewall.css?raw"
;
import
whitewallThemeContent
from
"../themes/whitewall.css?raw"
;
const
VALID_THEMES
=
[
"default"
,
"default-dark"
,
"paper"
,
"whitewall"
]
as
const
;
const
VALID_THEMES
=
[
"
system"
,
"
default"
,
"default-dark"
,
"paper"
,
"whitewall"
]
as
const
;
type
ValidTheme
=
(
typeof
VALID_THEMES
)[
number
];
type
ValidTheme
=
(
typeof
VALID_THEMES
)[
number
];
const
THEME_CONTENT
:
Record
<
ValidTheme
,
string
|
null
>
=
{
const
THEME_CONTENT
:
Record
<
ValidTheme
,
string
|
null
>
=
{
system
:
null
,
// System theme dynamically chooses between default and default-dark
default
:
null
,
default
:
null
,
"default-dark"
:
defaultDarkThemeContent
,
"default-dark"
:
defaultDarkThemeContent
,
paper
:
paperThemeContent
,
paper
:
paperThemeContent
,
...
@@ -18,8 +19,9 @@ export interface ThemeOption {
...
@@ -18,8 +19,9 @@ export interface ThemeOption {
}
}
export
const
THEME_OPTIONS
:
ThemeOption
[]
=
[
export
const
THEME_OPTIONS
:
ThemeOption
[]
=
[
{
value
:
"default"
,
label
:
"Default Light"
},
{
value
:
"system"
,
label
:
"Sync with system"
},
{
value
:
"default-dark"
,
label
:
"Default Dark"
},
{
value
:
"default"
,
label
:
"Light"
},
{
value
:
"default-dark"
,
label
:
"Dark"
},
{
value
:
"paper"
,
label
:
"Paper"
},
{
value
:
"paper"
,
label
:
"Paper"
},
{
value
:
"whitewall"
,
label
:
"Whitewall"
},
{
value
:
"whitewall"
,
label
:
"Whitewall"
},
];
];
...
@@ -38,6 +40,18 @@ export const getSystemTheme = (): "default" | "default-dark" => {
...
@@ -38,6 +40,18 @@ export const getSystemTheme = (): "default" | "default-dark" => {
return
"default"
;
return
"default"
;
};
};
/**
* Resolves the actual theme to apply based on user preference
* If theme is "system", returns the system preference, otherwise returns the theme as-is
*/
export
const
resolveTheme
=
(
theme
:
string
):
"default"
|
"default-dark"
|
"paper"
|
"whitewall"
=>
{
if
(
theme
===
"system"
)
{
return
getSystemTheme
();
}
const
validTheme
=
validateTheme
(
theme
);
return
validTheme
===
"system"
?
getSystemTheme
()
:
validTheme
;
};
/**
/**
* Gets the theme that should be applied on initial load
* Gets the theme that should be applied on initial load
* Priority: stored user preference -> system preference -> default
* Priority: stored user preference -> system preference -> default
...
@@ -53,8 +67,8 @@ export const getInitialTheme = (): ValidTheme => {
...
@@ -53,8 +67,8 @@ export const getInitialTheme = (): ValidTheme => {
// localStorage might not be available
// localStorage might not be available
}
}
// Fall back to system preference
// Fall back to system preference
(return "system" to enable auto-switching)
return
getSystemTheme
()
;
return
"system"
;
};
};
/**
/**
...
@@ -68,12 +82,15 @@ export const applyThemeEarly = (): void => {
...
@@ -68,12 +82,15 @@ export const applyThemeEarly = (): void => {
export
const
loadTheme
=
(
themeName
:
string
):
void
=>
{
export
const
loadTheme
=
(
themeName
:
string
):
void
=>
{
const
validTheme
=
validateTheme
(
themeName
);
const
validTheme
=
validateTheme
(
themeName
);
// Resolve "system" to actual theme based on OS preference
const
resolvedTheme
=
resolveTheme
(
validTheme
);
// Remove existing theme
// Remove existing theme
document
.
getElementById
(
"instance-theme"
)?.
remove
();
document
.
getElementById
(
"instance-theme"
)?.
remove
();
// Apply theme (skip for default)
// Apply theme (skip for default)
if
(
vali
dTheme
!==
"default"
)
{
if
(
resolve
dTheme
!==
"default"
)
{
const
css
=
THEME_CONTENT
[
vali
dTheme
];
const
css
=
THEME_CONTENT
[
resolve
dTheme
];
if
(
css
)
{
if
(
css
)
{
const
style
=
document
.
createElement
(
"style"
);
const
style
=
document
.
createElement
(
"style"
);
style
.
id
=
"instance-theme"
;
style
.
id
=
"instance-theme"
;
...
@@ -82,13 +99,44 @@ export const loadTheme = (themeName: string): void => {
...
@@ -82,13 +99,44 @@ export const loadTheme = (themeName: string): void => {
}
}
}
}
// Set data attribute
// Set data attribute
with resolved theme
document
.
documentElement
.
setAttribute
(
"data-theme"
,
vali
dTheme
);
document
.
documentElement
.
setAttribute
(
"data-theme"
,
resolve
dTheme
);
// Store theme preference for future loads
// Store theme preference
(original, not resolved)
for future loads
try
{
try
{
localStorage
.
setItem
(
"memos-theme"
,
validTheme
);
localStorage
.
setItem
(
"memos-theme"
,
validTheme
);
}
catch
{
}
catch
{
// localStorage might not be available
// localStorage might not be available
}
}
};
};
/**
* Sets up a listener for system theme preference changes
* Returns a cleanup function to remove the listener
*/
export
const
setupSystemThemeListener
=
(
onThemeChange
:
()
=>
void
):
(()
=>
void
)
=>
{
if
(
typeof
window
===
"undefined"
||
!
window
.
matchMedia
)
{
return
()
=>
{};
// No-op cleanup
}
const
mediaQuery
=
window
.
matchMedia
(
"(prefers-color-scheme: dark)"
);
// Handle theme change
const
handleChange
=
()
=>
{
onThemeChange
();
};
// Modern API (addEventListener)
if
(
mediaQuery
.
addEventListener
)
{
mediaQuery
.
addEventListener
(
"change"
,
handleChange
);
return
()
=>
mediaQuery
.
removeEventListener
(
"change"
,
handleChange
);
}
// Legacy API (addListener) - for older browsers
if
(
mediaQuery
.
addListener
)
{
mediaQuery
.
addListener
(
handleChange
);
return
()
=>
mediaQuery
.
removeListener
(
handleChange
);
}
return
()
=>
{};
// No-op cleanup
};
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment