From 649f361385a1f6b6180fd86d46a84ab27fb5dab3 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 23:24:52 +0200 Subject: [PATCH] feat(settings): per-theme visibility toggle (#2894) Theme options gain an optional hidden flag in their JSON config. The admin theme table gets a Visible checkbox per row; hidden themes are filtered out of the user-facing theme picker and skipped by the preferred-theme resolver so a hidden default or previously-selected theme falls back cleanly. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- frontend/public/locales/en-US/dashboard.json | 5 +++-- frontend/public/locales/zh-CN/dashboard.json | 5 +++-- frontend/src/App.tsx | 12 +++++++++--- .../Admin/Settings/Appearance/ThemeOptions.tsx | 18 ++++++++++++++++++ .../Pages/Setting/PreferenceSetting.tsx | 18 ++++++++++-------- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 377e0c48..2b1c8340 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -841,7 +841,8 @@ "ssoRegisterEnabled": "Allow automatic registration", "ssoRegisterEnabledDes": "Automatically create a local account when a user signs in via SSO for the first time. The sign-up email filter below also applies.", "ssoCallbackUrl": "Callback URL", - "ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}" + "ssoCallbackUrlDes": "Register this URL as the redirect/callback URL in your identity provider: <0>{{url}}", + "themeVisible": "Visible" }, "giftCodes": { "giftCodesSettings": "Gift Codes", @@ -1783,4 +1784,4 @@ "shareID": "Shared ID", "reason": "Reason" } -} \ No newline at end of file +} diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 7facd465..6830b649 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -841,7 +841,8 @@ "ssoRegisterEnabled": "允许自动注册", "ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。", "ssoCallbackUrl": "回调地址", - "ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}" + "ssoCallbackUrlDes": "请在身份提供方中将此 URL 注册为 Redirect/Callback URL:<0>{{url}}", + "themeVisible": "可见" }, "giftCodes": { "giftCodesSettings": "礼品码", @@ -1782,4 +1783,4 @@ "shareID": "分享 ID", "reason": "原因" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77f7dd27..16ad94bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -242,6 +242,7 @@ interface themeOptions { interface singleThemeOption { light: ThemeOptions; dark?: ThemeOptions; + hidden?: boolean; } const getPreferredTheme = ( @@ -250,16 +251,21 @@ const getPreferredTheme = ( preferredTheme?: string, defaultTheme?: string, ): ThemeOptions => { + // Hidden themes are not user-selectable; fall back to the default when the + // preferred (or the last remaining) theme is hidden. + const visible = Object.values(opts).filter((o) => !o.hidden); + const pool = visible.length > 0 ? visible : Object.values(opts); + let themeConfig = {} as singleThemeOption; - if (defaultTheme && opts[defaultTheme]) { + if (defaultTheme && opts[defaultTheme] && !opts[defaultTheme].hidden) { themeConfig = opts[defaultTheme]; } - if (preferredTheme && opts[preferredTheme]) { + if (preferredTheme && opts[preferredTheme] && !opts[preferredTheme].hidden) { themeConfig = opts[preferredTheme]; } if (!themeConfig?.light) { - themeConfig = Object.values(opts)[0]; + themeConfig = pool[0]; } if (mode === "dark" && themeConfig.dark) { diff --git a/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx b/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx index e61c1665..a4911837 100644 --- a/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx +++ b/frontend/src/component/Admin/Settings/Appearance/ThemeOptions.tsx @@ -36,6 +36,7 @@ export interface ThemeOptionsProps { interface ThemeOption { id: string; config: { + hidden?: boolean; light: { palette: { primary: { @@ -234,6 +235,15 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T [onDefaultThemeChange], ); + const handleVisibilityChange = useCallback( + (id: string, visible: boolean) => { + const newOptions = { ...options }; + newOptions[id] = { ...newOptions[id], hidden: !visible }; + handleSave(newOptions); + }, + [options, handleSave], + ); + const optionsArray = useMemo(() => { return Object.entries(options).map(([id, config]) => ({ id, @@ -256,6 +266,7 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T {t("settings.defaultTheme")} + {t("settings.themeVisible")} {t("settings.primaryColor")} {t("settings.secondaryColor")} {t("settings.primaryColorDark")} @@ -273,6 +284,13 @@ const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: T onChange={() => handleDefaultThemeChange(option.id)} /> + + handleVisibilityChange(option.id, e.target.checked)} + /> + { - {Object.keys(themeOptions).map((color, index) => ( - applyTheme(color)} - selected={(preferredTheme && preferredTheme == color) || (!preferredTheme && defaultTheme == color)} - /> - ))} + {Object.keys(themeOptions) + .filter((color) => !themeOptions[color]?.hidden) + .map((color, index) => ( + applyTheme(color)} + selected={(preferredTheme && preferredTheme == color) || (!preferredTheme && defaultTheme == color)} + /> + ))}