refactor: migrate theme management to next-themes (#11112)

* refactor: migrate theme management to `next-themes` and remove theme from route variants and global store.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: Unify theme mode to 'system' instead of 'auto' and streamline Electron theme synchronization.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: Remove LOBE_THEME_APPEARANCE constant and simplify desktop theme source assignment.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: Update antd-style dependency from npm alias to specific alpha version.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: update pnpm lockfile

Signed-off-by: Innei <tukon479@gmail.com>

* feat: Default theme to system and update Next.js RSC payload path example.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add `dev:static` script for static renderer development

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: replace useThemeMode with custom useIsDark hook for theme detection and add ClientOnly component

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: Remove `extractStaticStyle` import and cache prop from `StyleRegistry`.

Signed-off-by: Innei <tukon479@gmail.com>

* chore: Remove debug console log for current appearance.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: Migrate legacy 'auto' theme mode to 'system' and refine theme background CSS selectors.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: Add window dragging to desktop onboarding layout and update antd-style dependency.

* refactor: Refine global background styling to target body elements, remove token-based background, and clean up debugging script.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-01-05 13:23:43 +08:00 committed by GitHub
parent 4196d9783e
commit 3a30d9aed1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 402 additions and 475 deletions

View file

@ -18,6 +18,7 @@
"build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.js --publish never",
"build:win": "npm run build && electron-builder --win --config electron-builder.js --publish never",
"dev": "electron-vite dev",
"dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev",
"electron:dev": "electron-vite dev",
"electron:run-unpack": "electron .",
"format": "prettier --write ",
@ -57,11 +58,11 @@
"@lobechat/file-loaders": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.24.3",
"@t3-oss/env-core": "^0.13.8",
"@types/async-retry": "^1.4.9",
"@types/resolve": "^1.20.6",
"@types/semver": "^7.7.1",
"@types/set-cookie-parser": "^2.4.10",
"@t3-oss/env-core": "^0.13.8",
"@typescript/native-preview": "7.0.0-dev.20251210.1",
"async-retry": "^1.3.3",
"consola": "^3.4.2",
@ -104,4 +105,4 @@
"electron-builder"
]
}
}
}

View file

@ -31,5 +31,5 @@ export const STORE_DEFAULTS: ElectronMainStore = {
networkProxy: defaultProxySettings,
shortcuts: DEFAULT_SHORTCUTS_CONFIG,
storagePath: appStorageDir,
themeMode: 'auto',
themeMode: 'system',
};

View file

@ -258,9 +258,8 @@ export default class SystemController extends ControllerModule {
return nativeTheme.themeSource;
}
@IpcMethod()
async setSystemThemeMode(themeMode: ThemeMode) {
nativeTheme.themeSource = themeMode === 'auto' ? 'system' : themeMode;
private async setSystemThemeMode(themeMode: ThemeMode) {
nativeTheme.themeSource = themeMode;
}
/**

View file

@ -142,10 +142,17 @@ export class App {
* This allows nativeTheme.shouldUseDarkColors to be used consistently everywhere
*/
private initializeThemeMode() {
const themeMode = this.storeManager.get('themeMode');
let themeMode = this.storeManager.get('themeMode');
// Migrate legacy 'auto' value to 'system' (nativeTheme.themeSource doesn't accept 'auto')
if (Object.is(themeMode, 'auto')) {
themeMode = 'system';
this.storeManager.set('themeMode', themeMode);
logger.info(`Migrated legacy theme mode 'auto' to 'system'`);
}
if (themeMode) {
nativeTheme.themeSource = themeMode === 'auto' ? 'system' : themeMode;
nativeTheme.themeSource = themeMode;
logger.debug(
`Theme mode initialized to: ${themeMode} (themeSource: ${nativeTheme.themeSource})`,
);
@ -401,4 +408,4 @@ export class App {
// 执行清理操作
this.staticFileServerManager.destroy();
};
}
}

View file

@ -11,7 +11,7 @@ export interface ElectronMainStore {
networkProxy: NetworkProxySettings;
shortcuts: Record<string, string>;
storagePath: string;
themeMode: 'dark' | 'light' | 'auto';
themeMode: 'dark' | 'light' | 'system';
}
export type StoreKey = keyof ElectronMainStore;

View file

@ -205,7 +205,7 @@
"@lobehub/icons": "^4.0.2",
"@lobehub/market-sdk": "^0.25.1",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.8.0",
"@lobehub/ui": "^4.9.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^16.1.1",
@ -234,7 +234,7 @@
"@zumer/snapdom": "^1.9.14",
"ahooks": "^3.9.6",
"antd": "^6.1.1",
"antd-style": "^4.1.0",
"antd-style": "4.1.0",
"async-retry": "^1.3.3",
"bcryptjs": "^3.0.3",
"better-auth": "1.4.6",
@ -281,6 +281,7 @@
"next": "^16.1.1",
"next-auth": "5.0.0-beta.30",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
"node-machine-id": "^1.1.12",
"nodemailer": "^7.0.11",
@ -453,4 +454,4 @@
"access": "public",
"registry": "https://registry.npmjs.org"
}
}
}

View file

@ -2,7 +2,8 @@
import { FileSearchResult } from '@lobechat/types';
import { Center, Flexbox, MaterialFileTypeIcon, Text, Tooltip } from '@lobehub/ui';
import { cx, useThemeMode } from 'antd-style';
import { cx } from 'antd-style';
import { useTheme } from 'next-themes';
import { memo } from 'react';
import { styles } from './style';
@ -12,7 +13,8 @@ export interface FileItemProps extends FileSearchResult {
}
const FileItem = memo<FileItemProps>(({ fileId, fileName, relevanceScore }) => {
const { isDarkMode } = useThemeMode();
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === 'dark';
return (
<Flexbox

View file

@ -1,9 +1,9 @@
import { type EditLocalFileParams } from '@lobechat/electron-client-ipc';
import { type BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Icon, Skeleton, Text } from '@lobehub/ui';
import { useThemeMode } from 'antd-style';
import { createPatch } from 'diff';
import { ChevronRight } from 'lucide-react';
import { useTheme } from 'next-themes';
import path from 'path-browserify-esm';
import React, { memo, useMemo } from 'react';
import { Diff, Hunk, parseDiff } from 'react-diff-view';
@ -29,7 +29,8 @@ const EditLocalFile = memo<BuiltinInterventionProps<EditLocalFileParams>>(({ arg
},
);
const { isDarkMode } = useThemeMode();
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === 'dark';
// Generate diff from full file content
const files = useMemo(() => {
if (!fileData?.content) return [];

View file

@ -2,8 +2,8 @@ import { type EditLocalFileState } from '@lobechat/builtin-tool-local-system';
import { type EditLocalFileParams } from '@lobechat/electron-client-ipc';
import { type BuiltinRenderProps } from '@lobechat/types';
import { Alert, Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { useThemeMode } from 'antd-style';
import { ChevronRight } from 'lucide-react';
import { useTheme } from 'next-themes';
import path from 'path-browserify-esm';
import React, { memo, useMemo } from 'react';
import { Diff, Hunk, parseDiff } from 'react-diff-view';
@ -28,7 +28,8 @@ const EditLocalFile = memo<BuiltinRenderProps<EditLocalFileParams, EditLocalFile
return [];
}
}, [pluginState?.diffText]);
const { isDarkMode } = useThemeMode();
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === 'dark';
if (!args) return <Skeleton active />;

View file

@ -1,5 +1,3 @@
export const LOBE_THEME_APPEARANCE = 'LOBE_THEME_APPEARANCE';
export const LOBE_THEME_PRIMARY_COLOR = 'LOBE_THEME_PRIMARY_COLOR';
export const LOBE_THEME_NEUTRAL_COLOR = 'LOBE_THEME_NEUTRAL_COLOR';

View file

@ -31,31 +31,26 @@ export interface IRouteVariants {
locale: Locales;
neutralColor?: string;
primaryColor?: string;
theme: 'dark' | 'light';
}
const SUPPORTED_THEMES = ['dark', 'light'] as const;
export const DEFAULT_VARIANTS: IRouteVariants = {
isMobile: false,
locale: DEFAULT_LANG,
theme: 'light',
};
const SPLITTER = '__';
export class RouteVariants {
static serializeVariants = (variants: IRouteVariants): string =>
[variants.locale, Number(variants.isMobile), variants.theme].join(SPLITTER);
[variants.locale, Number(variants.isMobile)].join(SPLITTER);
static deserializeVariants = (serialized: string): IRouteVariants => {
try {
const [locale, isMobile, theme] = serialized.split(SPLITTER);
const [locale, isMobile] = serialized.split(SPLITTER);
return {
isMobile: isMobile === '1',
locale: RouteVariants.isValidLocale(locale) ? (locale as Locales) : DEFAULT_VARIANTS.locale,
theme: RouteVariants.isValidTheme(theme) ? (theme as any) : DEFAULT_VARIANTS.theme,
};
} catch {
return { ...DEFAULT_VARIANTS };
@ -68,6 +63,4 @@ export class RouteVariants {
});
private static isValidLocale = (locale: string): boolean => locales.includes(locale as any);
private static isValidTheme = (theme: string): boolean => SUPPORTED_THEMES.includes(theme as any);
}

View file

@ -25,5 +25,5 @@ export interface UserPathData {
videos?: string; // User's home directory
}
export type ThemeMode = 'auto' | 'dark' | 'light';
export type ThemeMode = 'system' | 'dark' | 'light';
export type ThemeAppearance = 'dark' | 'light' | string;

View file

@ -2,17 +2,18 @@
import { Center, Flexbox, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cx, useThemeMode } from 'antd-style';
import { cx } from 'antd-style';
import type { FC, PropsWithChildren } from 'react';
import { ProductLogo } from '@/components/Branding';
import LangButton from '@/features/User/UserPanel/LangButton';
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
import { useIsDark } from '@/hooks/useIsDark';
import { styles } from './style';
const AuthContainer: FC<PropsWithChildren> = ({ children }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return (
<Flexbox className={styles.outerContainer} height={'100%'} padding={8} width={'100%'}>
<Flexbox

View file

@ -1,18 +1,13 @@
import { createStaticStyles } from 'antd-style';
export const styles = createStaticStyles(({ css, cssVar }) => ({
// Divider 样式
divider: css`
// Divider 样式
divider: css`
height: 24px;
`,
// 内层容器 - 深色模式
innerContainerDark: css`
// 内层容器 - 深色模式
innerContainerDark: css`
position: relative;
overflow: hidden;
@ -23,11 +18,8 @@ innerContainerDark: css`
background: ${cssVar.colorBgContainer};
`,
// 内层容器 - 浅色模式
innerContainerLight: css`
// 内层容器 - 浅色模式
innerContainerLight: css`
position: relative;
overflow: hidden;
@ -38,10 +30,8 @@ innerContainerLight: css`
background: ${cssVar.colorBgContainer};
`,
// 外层容器
outerContainer: css`
// 外层容器
outerContainer: css`
position: relative;
`,
}));

View file

@ -1,13 +1,17 @@
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import { type FC, type PropsWithChildren } from 'react';
import ClientOnly from '@/components/client/ClientOnly';
import AuthContainer from './_layout';
const AuthLayout: FC<PropsWithChildren> = ({ children }) => {
return (
<NuqsAdapter>
<AuthContainer>{children}</AuthContainer>
</NuqsAdapter>
<ClientOnly>
<NuqsAdapter>
<AuthContainer>{children}</AuthContainer>
</NuqsAdapter>
</ClientOnly>
);
};

View file

@ -2,16 +2,17 @@
import { Center, Flexbox, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cx, useThemeMode } from 'antd-style';
import { cx } from 'antd-style';
import type { FC, PropsWithChildren } from 'react';
import LangButton from '@/features/User/UserPanel/LangButton';
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
import { useIsDark } from '@/hooks/useIsDark';
import { styles } from './style';
const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return (
<Flexbox className={styles.outerContainer} height={'100%'} padding={8} width={'100%'}>
<Flexbox
@ -21,6 +22,7 @@ const OnboardingContainer: FC<PropsWithChildren> = ({ children }) => {
>
<Flexbox
align={'center'}
className={cx(styles.drag)}
gap={8}
horizontal
justify={'space-between'}

View file

@ -6,6 +6,9 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
height: 24px;
`,
drag: css`
-webkit-app-region: drag;
`,
// 内层容器 - 深色模式
innerContainerDark: css`
position: relative;

View file

@ -1,15 +1,16 @@
import { Flexbox } from '@lobehub/ui';
import { cssVar, useThemeMode } from 'antd-style';
import { cssVar } from 'antd-style';
import { type FC, type PropsWithChildren, useMemo } from 'react';
import { isDesktop } from '@/const/version';
import { useIsDark } from '@/hooks/useIsDark';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { styles } from './DesktopLayoutContainer/style';
const DesktopLayoutContainer: FC<PropsWithChildren> = ({ children }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const [expand] = useGlobalStore((s) => [systemStatusSelectors.showLeftPanel(s)]);
// CSS 变量用于动态样式

View file

@ -2,7 +2,7 @@
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Icon, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { AlertCircle, X } from 'lucide-react';
import Image from 'next/image';
@ -10,6 +10,7 @@ import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import { useIsDark } from '@/hooks/useIsDark';
import { useDiscoverStore } from '@/store/discover';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
@ -53,7 +54,7 @@ interface PluginTagProps {
}
const PluginTag = memo<PluginTagProps>(({ pluginId, onRemove }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { t } = useTranslation('setting');
// Extract identifier

View file

@ -1,9 +1,10 @@
import { Button, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import DefaultFooter from '@/features/Setting/Footer';
import { useIsDark } from '@/hooks/useIsDark';
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
const styles = createStaticStyles(({ css }) => ({
@ -27,7 +28,7 @@ const styles = createStaticStyles(({ css }) => ({
const Footer = memo(() => {
const { t } = useTranslation('discover');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { isAuthenticated, signIn } = useMarketAuth();
const [loading, setLoading] = useState(false);
const handleSignIn = useCallback(async () => {

View file

@ -1,9 +1,10 @@
import { Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { type CSSProperties, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsDark } from '@/hooks/useIsDark';
import { useChatStore } from '@/store/chat';
import { threadSelectors } from '@/store/chat/selectors';
@ -32,7 +33,7 @@ interface ThreadProps {
const Thread = memo<ThreadProps>(({ id, placement, style }) => {
const { t } = useTranslation('chat');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const threads = useChatStore(threadSelectors.getThreadsBySourceMsgId(id), isEqual);

View file

@ -16,7 +16,6 @@ const AgentBuilder = memo(() => {
const setChatPanelExpanded = useProfileStore((s) => s.setChatPanelExpanded);
const groupAgentBuilderId = useAgentStore(builtinAgentSelectors.groupAgentBuilderId);
console.log('groupAgentBuilderId', groupAgentBuilderId);
const [width, setWidth] = useState<string | number>(360);
const useInitBuiltinAgent = useAgentStore((s) => s.useInitBuiltinAgent);

View file

@ -2,7 +2,7 @@
import { KLAVIS_SERVER_TYPES, type KlavisServerType } from '@lobechat/const';
import { Avatar, Icon, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { AlertCircle, X } from 'lucide-react';
import Image from 'next/image';
@ -10,6 +10,7 @@ import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PluginAvatar from '@/components/Plugins/PluginAvatar';
import { useIsDark } from '@/hooks/useIsDark';
import { useDiscoverStore } from '@/store/discover';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
@ -53,7 +54,7 @@ interface PluginTagProps {
}
const PluginTag = memo<PluginTagProps>(({ pluginId, onRemove }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { t } = useTranslation('setting');
// Extract identifier

View file

@ -1,9 +1,9 @@
import { Avatar, Block, Flexbox, Input } from '@lobehub/ui';
import { Popover } from 'antd';
import { useThemeMode } from 'antd-style';
import { memo, useCallback, useState } from 'react';
import EmojiPicker from '@/components/EmojiPicker';
import { useIsDark } from '@/hooks/useIsDark';
import { useAgentStore } from '@/store/agent';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
@ -18,7 +18,7 @@ interface EditingProps {
const Editing = memo<EditingProps>(({ id, title, avatar, toggleEditing }) => {
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const editing = useHomeStore((s) => s.agentRenamingId === id);

View file

@ -118,7 +118,7 @@ const Footer = memo(() => {
</a>
)}
</Flexbox>
<ThemeButton placement={'top'} size={16} />
<ThemeButton placement={'topCenter'} size={16} />
</Flexbox>
<LabsModal onClose={handleCloseLabsModal} open={isLabsModalOpen} />
<ChangelogModal

View file

@ -1,8 +1,9 @@
import { Flexbox } from '@lobehub/ui';
import { useTheme, useThemeMode } from 'antd-style';
import { useTheme } from 'antd-style';
import { Activity, type FC, type ReactNode, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useIsDark } from '@/hooks/useIsDark';
import { useHomeStore } from '@/store/home';
import RecentHydration from './RecentHydration';
@ -14,7 +15,7 @@ interface LayoutProps {
}
const Layout: FC<LayoutProps> = ({ children }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const theme = useTheme(); // Keep for colorBgContainerSecondary (not in cssVar)
const navigate = useNavigate();
const { pathname } = useLocation();

View file

@ -1,14 +1,15 @@
import { Avatar, Block, Flexbox, Text } from '@lobehub/ui';
import { cssVar, useThemeMode } from 'antd-style';
import { cssVar } from 'antd-style';
import { memo } from 'react';
import { RECENT_BLOCK_SIZE } from '@/app/[variants]/(main)/home/features/const';
import { DEFAULT_AVATAR } from '@/const/meta';
import { useIsDark } from '@/hooks/useIsDark';
import { type DiscoverAssistantItem } from '@/types/discover';
const CommunityAgentItem = memo<DiscoverAssistantItem>(
({ title, avatar, backgroundColor, author, description }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return (
<Block

View file

@ -1,10 +1,12 @@
'use client';
import { Block, Center, Grid, type GridProps, Text } from '@lobehub/ui';
import { cssVar, useThemeMode } from 'antd-style';
import { cssVar } from 'antd-style';
import { memo } from 'react';
import useMergeState from 'use-merge-value';
import { useIsDark } from '@/hooks/useIsDark';
export interface AspectRatioSelectProps extends Omit<GridProps, 'children' | 'onChange'> {
defaultValue?: string;
onChange?: (value: string) => void;
@ -14,7 +16,7 @@ export interface AspectRatioSelectProps extends Omit<GridProps, 'children' | 'on
const AspectRatioSelect = memo<AspectRatioSelectProps>(
({ options, onChange, value, defaultValue, ...rest }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const [active, setActive] = useMergeState('1:1', {
defaultValue: defaultValue || '1:1',
onChange,

View file

@ -1,12 +1,13 @@
import { ModelIcon } from '@lobehub/icons';
import { Flexbox, Text } from '@lobehub/ui';
import { Popover } from 'antd';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import { type AiModelForSelect } from 'model-bank';
import numeral from 'numeral';
import { memo, useMemo } from 'react';
import NewModelBadge from '@/components/ModelSelect/NewModelBadge';
import { useIsDark } from '@/hooks/useIsDark';
const POPOVER_MAX_WIDTH = 320;
@ -59,7 +60,7 @@ const ImageModelItem = memo<ImageModelItemProps>(
showBadge = true,
...model
}) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const priceLabel = useMemo(() => {
// Priority 1: Use exact price

View file

@ -1,10 +1,12 @@
'use client';
import { Block, Center, Grid, type GridProps, Select, Text } from '@lobehub/ui';
import { cssVar, useThemeMode } from 'antd-style';
import { cssVar } from 'antd-style';
import { type ReactNode, memo } from 'react';
import useMergeState from 'use-merge-value';
import { useIsDark } from '@/hooks/useIsDark';
export interface SizeSelectProps extends Omit<GridProps, 'children' | 'onChange'> {
defaultValue?: 'auto' | string;
onChange?: (value: string) => void;
@ -26,7 +28,7 @@ const canParseAsRatio = (value: string): boolean => {
};
const SizeSelect = memo<SizeSelectProps>(({ options, onChange, value, defaultValue, ...rest }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const [active, setActive] = useMergeState('auto', {
defaultValue,
onChange,

View file

@ -2,13 +2,14 @@
import { ChatInput } from '@lobehub/editor/react';
import { Button, Flexbox, TextArea } from '@lobehub/ui';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import { Sparkles } from 'lucide-react';
import type { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { loginRequired } from '@/components/Error/loginRequiredNotification';
import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
import { useIsDark } from '@/hooks/useIsDark';
import { useImageStore } from '@/store/image';
import { createImageSelectors } from '@/store/image/selectors';
import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
@ -39,7 +40,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
}));
const PromptInput = ({ showTitle = false }: PromptInputProps) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { t } = useTranslation('image');
const { value, setValue } = useGenerationConfigParam('prompt');
const isCreating = useImageStore(createImageSelectors.isCreating);

View file

@ -1,11 +1,12 @@
'use client';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import dayjs from 'dayjs';
import { type ReactNode, memo, useMemo } from 'react';
import { GroupedVirtuoso } from 'react-virtuoso';
import Loading from '@/app/[variants]/(main)/memory/features/Loading';
import { useIsDark } from '@/hooks/useIsDark';
import { useScrollParent } from './useScrollParent';
@ -31,7 +32,9 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
export type GroupBy = 'day' | 'month';
interface TimelineViewProps<T extends { capturedAt?: Date | string; createdAt?: Date | string; id: string }> {
interface TimelineViewProps<
T extends { capturedAt?: Date | string; createdAt?: Date | string; id: string },
> {
data: T[];
/**
* Custom date field extractor for grouping
@ -68,7 +71,9 @@ const getDateValue = <T extends { capturedAt?: Date | string; createdAt?: Date |
return item.capturedAt ?? item.createdAt ?? new Date();
};
function TimelineViewInner<T extends { capturedAt?: Date | string; createdAt?: Date | string; id: string }>({
function TimelineViewInner<
T extends { capturedAt?: Date | string; createdAt?: Date | string; id: string },
>({
data,
groupBy = 'day',
getDateForGrouping,
@ -78,7 +83,7 @@ function TimelineViewInner<T extends { capturedAt?: Date | string; createdAt?: D
renderHeader,
renderItem,
}: TimelineViewProps<T>) {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const scrollParent = useScrollParent();
const { groupCounts, sortedPeriods, groupedItems } = useMemo(() => {

View file

@ -1,10 +1,10 @@
import { Block, Flexbox, Input } from '@lobehub/ui';
import { Popover } from 'antd';
import { useThemeMode } from 'antd-style';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiPicker from '@/components/EmojiPicker';
import { useIsDark } from '@/hooks/useIsDark';
import { useFileStore } from '@/store/file';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
@ -18,7 +18,7 @@ interface EditingProps {
const Editing = memo<EditingProps>(({ documentId, title, currentEmoji, toggleEditing }) => {
const locale = useGlobalStore(globalGeneralSelectors.currentLanguage);
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { t } = useTranslation('file');
const editing = useFileStore((s) => s.renamingPageId === documentId);

View file

@ -5,6 +5,7 @@ import { Select, Skeleton } from '@lobehub/ui';
import { Segmented, Switch } from 'antd';
import isEqual from 'fast-deep-equal';
import { Ban, Gauge, Loader2Icon, Monitor, Moon, Mouse, Sun, Waves } from 'lucide-react';
import { useTheme as useNextThemesTheme } from 'next-themes';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -22,16 +23,15 @@ const Common = memo(() => {
const { t } = useTranslation('setting');
const general = useUserStore((s) => settingsSelectors.currentSettings(s).general, isEqual);
const themeMode = useGlobalStore(systemStatusSelectors.themeMode);
const { theme, setTheme } = useNextThemesTheme();
const language = useGlobalStore(systemStatusSelectors.language);
const [setSettings, isUserStateInit] = useUserStore((s) => [s.setSettings, s.isUserStateInit]);
const [setThemeMode, switchLocale, isStatusInit] = useGlobalStore((s) => [
s.switchThemeMode,
s.switchLocale,
s.isStatusInit,
]);
const [switchLocale, isStatusInit] = useGlobalStore((s) => [s.switchLocale, s.isStatusInit]);
const [loading, setLoading] = useState(false);
// Use the theme value from next-themes, default to 'system'
const currentTheme = theme || 'system';
const handleLangChange = (value: LocaleMode) => {
switchLocale(value);
};
@ -39,13 +39,13 @@ const Common = memo(() => {
if (!(isStatusInit && isUserStateInit))
return <Skeleton active paragraph={{ rows: 5 }} title={false} />;
const theme: FormGroupItemType = {
const themeFormGroup: FormGroupItemType = {
children: [
{
children: (
<ImageSelect
height={60}
onChange={setThemeMode}
onChange={(value) => setTheme(value === 'auto' ? 'system' : value)}
options={[
{
icon: Sun,
@ -63,11 +63,11 @@ const Common = memo(() => {
icon: Monitor,
img: imageUrl('theme_auto.webp'),
label: t('settingCommon.themeMode.auto'),
value: 'auto',
value: 'system',
},
]}
unoptimized={isDesktop}
value={themeMode}
value={currentTheme}
width={100}
/>
),
@ -173,7 +173,7 @@ const Common = memo(() => {
<Form
collapsible={false}
initialValues={general}
items={[theme]}
items={[themeFormGroup]}
itemsType={'group'}
onValuesChange={async (v) => {
setLoading(true);

View file

@ -2,11 +2,12 @@ import { BRANDING_PROVIDER } from '@lobechat/business-const';
import { ProviderCombine, ProviderIcon } from '@lobehub/icons';
import { Avatar, Flexbox, Skeleton, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cssVar, cx, useThemeMode } from 'antd-style';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { BrandingProviderCard } from '@/business/client/features/BrandingProviderCard';
import { useIsDark } from '@/hooks/useIsDark';
import { type AiProviderListItem } from '@/types/aiProvider';
import EnableSwitch from './EnableSwitch';
@ -19,7 +20,7 @@ interface ProviderCardProps extends AiProviderListItem {
const ProviderCard = memo<ProviderCardProps>(
({ id, description, name, enabled, source, logo, loading, onProviderSelect }) => {
const { t } = useTranslation('providers');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
if (loading)
return (

View file

@ -1,14 +1,16 @@
import { Flexbox } from '@lobehub/ui';
import { cssVar, useThemeMode } from 'antd-style';
import { cssVar } from 'antd-style';
import { memo } from 'react';
import { useIsDark } from '@/hooks/useIsDark';
interface TotalCardProps {
count: string | number;
title: string;
}
const TotalCard = memo<TotalCardProps>(({ title, count }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return (
<Flexbox
padding={12}

View file

@ -2,28 +2,26 @@
import { ActionIcon } from '@lobehub/ui';
import { ChatHeader } from '@lobehub/ui/mobile';
import { useThemeMode } from 'antd-style';
import { Moon, Sun } from 'lucide-react';
import { useTheme as useNextThemesTheme } from 'next-themes';
import { memo } from 'react';
import { MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
import { useGlobalStore } from '@/store/global';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
import { useIsDark } from '@/hooks/useIsDark';
const Header = memo(() => {
const { isDarkMode } = useThemeMode();
const switchThemeMode = useGlobalStore((s) => s.switchThemeMode);
const { setTheme } = useNextThemesTheme();
const isDark = useIsDark();
return (
<ChatHeader
right={
<ActionIcon
icon={isDarkMode ? Moon : Sun}
onClick={() => switchThemeMode(isDarkMode ? 'light' : 'dark')}
icon={isDark ? Moon : Sun}
onClick={() => setTheme(isDark ? 'light' : 'dark')}
size={MOBILE_HEADER_ICON_SIZE}
/>
}
style={mobileHeaderSticky}
/>
);
});

View file

@ -1,6 +1,5 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { SpeedInsights } from '@vercel/speed-insights/next';
import { type ThemeAppearance } from 'antd-style';
import { type ResolvingViewport } from 'next';
import Script from 'next/script';
import { type ReactNode, Suspense } from 'react';
@ -26,7 +25,7 @@ export interface RootLayoutProps extends DynamicLayoutProps {
const RootLayout = async ({ children, params }: RootLayoutProps) => {
const { variants } = await params;
const { locale, isMobile, theme, primaryColor, neutralColor } =
const { locale, isMobile, primaryColor, neutralColor } =
RouteVariants.deserializeVariants(variants);
const direction = isRtlLang(locale) ? 'rtl' : 'ltr';
@ -34,7 +33,6 @@ const RootLayout = async ({ children, params }: RootLayoutProps) => {
const renderContent = () => {
return (
<GlobalProvider
appearance={theme}
isMobile={isMobile}
locale={locale}
neutralColor={neutralColor}
@ -50,8 +48,9 @@ const RootLayout = async ({ children, params }: RootLayoutProps) => {
};
return (
<html dir={direction} lang={locale}>
<html dir={direction} lang={locale} suppressHydrationWarning>
<head>
{/* <script dangerouslySetInnerHTML={{ __html: 'setTimeout(() => {debugger}, 16)' }} /> */}
{process.env.DEBUG_REACT_SCAN === '1' && (
<Script
crossOrigin={'anonymous'}
@ -99,7 +98,6 @@ export const generateViewport = async (props: DynamicLayoutProps): ResolvingView
};
export const generateStaticParams = () => {
const themes: ThemeAppearance[] = ['dark', 'light'];
const mobileOptions = isDesktop ? [false] : [true, false];
// only static for serveral page, other go to dynamtic
const staticLocales: Locales[] = [DEFAULT_LANG, 'zh-CN'];
@ -107,16 +105,13 @@ export const generateStaticParams = () => {
const variants: { variants: string }[] = [];
for (const locale of staticLocales) {
for (const theme of themes) {
for (const isMobile of mobileOptions) {
variants.push({
variants: RouteVariants.serializeVariants({
isMobile,
locale,
theme: theme as 'dark' | 'light',
}),
});
}
for (const isMobile of mobileOptions) {
variants.push({
variants: RouteVariants.serializeVariants({
isMobile,
locale,
}),
});
}
}

View file

@ -2,16 +2,17 @@
import { Center, Flexbox, Text } from '@lobehub/ui';
import { Divider } from 'antd';
import { cx, useThemeMode } from 'antd-style';
import { cx } from 'antd-style';
import type { FC, PropsWithChildren } from 'react';
import LangButton from '@/features/User/UserPanel/LangButton';
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
import { useIsDark } from '@/hooks/useIsDark';
import { styles } from './style';
const OnBoardingContainer: FC<PropsWithChildren> = ({ children }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return (
<Flexbox className={styles.outerContainer} height={'100%'} padding={8} width={'100%'}>
<Flexbox

View file

@ -1,13 +1,14 @@
'use client';
import { Block, Button, Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Undo2Icon } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import LobeMessage from '@/app/[variants]/onboarding/components/LobeMessage';
import { useIsDark } from '@/hooks/useIsDark';
import { useUserStore } from '@/store/user';
const styles = createStaticStyles(({ css, cssVar }) => ({
@ -76,7 +77,7 @@ interface ModeSelectionStepProps {
const ModeSelectionStep = memo<ModeSelectionStepProps>(({ onBack, onNext }) => {
const { t } = useTranslation('onboarding');
const navigate = useNavigate();
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const imageStyles = useMemo<React.CSSProperties>(
() =>

View file

@ -1,16 +1,20 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import Loading from '@/components/Loading/BrandTextLoading';
const DesktopRouterClient = dynamic(() => import('./DesktopClientRouter'), {
loading: () => <Loading debugId="DesktopRouter" />,
ssr: false,
});
import DesktopClientRouter from './DesktopClientRouter';
const useIsClient = () => {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
};
const DesktopRouter = () => {
return <DesktopRouterClient />;
const isClient = useIsClient();
if (!isClient) return null;
return <DesktopClientRouter />;
};
export default DesktopRouter;

View file

@ -1,8 +1,10 @@
'use client';
import { createStaticStyles, useThemeMode } from 'antd-style';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useIsDark } from '@/hooks/useIsDark';
const styles = createStaticStyles(({ css, cssVar }) => ({
dividerDark: css`
flex: none;
@ -19,7 +21,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
}));
const Divider = memo(() => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return <div className={isDarkMode ? styles.dividerDark : styles.dividerLight} />;
});

View file

@ -1,8 +1,10 @@
import { Flexbox, Icon, Modal } from '@lobehub/ui';
import { createStaticStyles, useThemeMode } from 'antd-style';
import { createStaticStyles } from 'antd-style';
import { type LucideIcon } from 'lucide-react';
import { type ReactNode, memo } from 'react';
import { useIsDark } from '@/hooks/useIsDark';
const prefixCls = 'ant';
const styles = createStaticStyles(({ css, cssVar }) => ({
@ -52,7 +54,7 @@ interface DataStyleModalProps {
const DataStyleModal = memo<DataStyleModalProps>(
({ icon, onOpenChange, title, open, children, width = 550, height }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return (
<Modal

View file

@ -1,8 +1,10 @@
import { Center, Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles, useThemeMode , responsive } from 'antd-style';
import { createStaticStyles, responsive } from 'antd-style';
import { type LucideIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsDark } from '@/hooks/useIsDark';
const styles = createStaticStyles(({ css, cssVar }) => ({
desc: css`
width: 280px;
@ -48,7 +50,7 @@ interface FeatureListProps {
}
const FeatureList = memo<FeatureListProps>(({ data }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
return (
<Flexbox gap={32}>

View file

@ -1,9 +1,10 @@
import { Flexbox, Icon, Tag, Tooltip } from '@lobehub/ui';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import { BoltIcon, RotateCwIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsDark } from '@/hooks/useIsDark';
import { AsyncTaskStatus, type FileParsingTask } from '@/types/asyncTask';
const styles = createStaticStyles(({ css, cssVar }) => ({
@ -36,7 +37,7 @@ interface EmbeddingStatusProps extends FileParsingTask {
const EmbeddingStatus = memo<EmbeddingStatusProps>(
({ chunkCount, embeddingStatus, embeddingError, onClick, onErrorClick, className }) => {
const { t } = useTranslation(['components', 'common']);
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
switch (embeddingStatus) {
case AsyncTaskStatus.Processing: {

View file

@ -1,10 +1,11 @@
import { Button, Flexbox, Icon, Tag, Tooltip } from '@lobehub/ui';
import { Badge } from 'antd';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { BoltIcon, Loader2Icon, RotateCwIcon } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsDark } from '@/hooks/useIsDark';
import { AsyncTaskStatus, type FileParsingTask } from '@/types/asyncTask';
import EmbeddingStatus from './EmbeddingStatus';
@ -52,7 +53,7 @@ const FileParsingStatus = memo<FileParsingStatusProps>(
hideEmbeddingButton,
}) => {
const { t } = useTranslation(['components', 'common']);
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
switch (chunkingStatus) {
case AsyncTaskStatus.Processing: {

View file

@ -1,10 +1,12 @@
'use client';
import { ActionIcon, Flexbox, type FlexboxProps } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { XIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsDark } from '@/hooks/useIsDark';
const styles = createStaticStyles(({ css }) => ({
cancelIcon: css`
position: absolute;
@ -70,7 +72,7 @@ const Notification = memo<NotificationProps>(
className,
...rest
}) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { className: wrapperClassName, ...restWrapper } = wrapper;
return (
show && (

View file

@ -0,0 +1,17 @@
'use client';
import { type FC, type PropsWithChildren, useEffect, useState } from 'react';
const ClientOnly: FC<PropsWithChildren> = ({ children }) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return children;
};
export default ClientOnly;

View file

@ -1,9 +1,9 @@
'use client';
import { LOBE_CHAT_CLOUD, UTM_SOURCE } from '@lobechat/business-const';
import { Button, Center, Flexbox, Icon , lobeStaticStylish } from '@lobehub/ui';
import { Button, Center, Flexbox, Icon, lobeStaticStylish } from '@lobehub/ui';
import { useSize } from 'ahooks';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
import { memo, useEffect, useRef, useState } from 'react';
@ -11,6 +11,7 @@ import Marquee from 'react-fast-marquee';
import { useTranslation } from 'react-i18next';
import { OFFICIAL_URL } from '@/const/url';
import { useIsDark } from '@/hooks/useIsDark';
import { isOnServerSide } from '@/utils/env';
export const BANNER_HEIGHT = 40;
@ -50,7 +51,7 @@ const CloudBanner = memo<{ mobile?: boolean }>(({ mobile }) => {
const contentRef = useRef(null);
const size = useSize(ref);
const contentSize = useSize(contentRef);
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { t } = useTranslation('common');
const [isTruncated, setIsTruncated] = useState(mobile);

View file

@ -24,7 +24,7 @@ const ThemeMenu = memo(() => {
<div className={styles.itemLabel}>{t('cmdk.themeDark')}</div>
</div>
</Command.Item>
<Command.Item onSelect={() => handleThemeChange('auto')} value="theme-auto">
<Command.Item onSelect={() => handleThemeChange('system')} value="theme-system">
<Monitor className={styles.icon} />
<div className={styles.itemContent}>
<div className={styles.itemLabel}>{t('cmdk.themeAuto')}</div>

View file

@ -4,7 +4,7 @@ export interface ChatMessage {
role: 'user' | 'assistant';
}
export type ThemeMode = 'light' | 'dark' | 'auto';
export type ThemeMode = 'light' | 'dark' | 'system';
export type PageType = 'theme' | 'ask-ai' | string;
@ -25,4 +25,7 @@ export type MenuContext =
| 'page'
| 'painting';
export type ContextType = Extract<MenuContext, 'agent' | 'group' | 'resource' | 'settings' | 'page' | 'painting'>;
export type ContextType = Extract<
MenuContext,
'agent' | 'group' | 'resource' | 'settings' | 'page' | 'painting'
>;

View file

@ -1,4 +1,5 @@
import { useDebounce } from 'ahooks';
import { useTheme as useNextThemesTheme } from 'next-themes';
import { useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
@ -37,7 +38,7 @@ export const useCommandMenu = () => {
} = useCommandMenuContext();
const navigate = useNavigate();
const switchThemeMode = useGlobalStore((s) => s.switchThemeMode);
const { setTheme } = useNextThemesTheme();
const createAgent = useAgentStore((s) => s.createAgent);
const refreshAgentList = useHomeStore((s) => s.refreshAgentList);
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
@ -106,7 +107,7 @@ export const useCommandMenu = () => {
};
const handleThemeChange = (theme: ThemeMode) => {
switchThemeMode(theme);
setTheme(theme);
closeCommandMenu();
};

View file

@ -1,9 +1,10 @@
import { Center, Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import { Loader2 } from 'lucide-react';
import { memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsDark } from '@/hooks/useIsDark';
import { useChatStore } from '@/store/chat';
import { chatPortalSelectors, messageStateSelectors } from '@/store/chat/selectors';
import { dotLoading } from '@/styles/loading';
@ -57,7 +58,7 @@ interface ArtifactProps extends MarkdownElementProps {
const Render = memo<ArtifactProps>(({ identifier, title, type, language, children, id }) => {
const { t } = useTranslation('chat');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const hasChildren = !!children;
const str = ((children as string) || '').toString?.();

View file

@ -1,9 +1,10 @@
import { type ChatFileChunk } from '@lobechat/types';
import { Center, Flexbox, Text, Tooltip } from '@lobehub/ui';
import { cx, useThemeMode } from 'antd-style';
import { cx } from 'antd-style';
import { memo } from 'react';
import FileIcon from '@/components/FileIcon';
import { useIsDark } from '@/hooks/useIsDark';
import { useChatStore } from '@/store/chat';
import { styles } from './style';
@ -13,7 +14,7 @@ export interface ChunkItemProps extends ChatFileChunk {
}
const ChunkItem = memo<ChunkItemProps>(({ id, fileId, similarity, text, filename, fileType }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
// Note: openFilePreview is a portal action, kept in ChatStore as it's a global UI state
const openFilePreview = useChatStore((s) => s.openFilePreview);

View file

@ -1,10 +1,12 @@
import { type ChatFileChunk } from '@lobechat/types';
import { Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { BookOpenTextIcon, ChevronDown, ChevronRight } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsDark } from '@/hooks/useIsDark';
import ChunkItem from './ChunkItem';
const styles = createStaticStyles(({ css }) => ({
@ -47,7 +49,7 @@ interface FileChunksProps {
const FileChunks = memo<FileChunksProps>(({ data }) => {
const { t } = useTranslation('chat');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const [showDetail, setShowDetail] = useState(false);

View file

@ -1,11 +1,12 @@
import { Flexbox, Icon, SearchResultCards, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { ChevronDown, ChevronRight, Globe } from 'lucide-react';
import { AnimatePresence, m as motion } from 'motion/react';
import Image from 'next/image';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsDark } from '@/hooks/useIsDark';
import { type GroundingSearch } from '@/types/search';
const styles = createStaticStyles(({ css, cssVar }) => ({
@ -46,7 +47,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
const SearchGrounding = memo<GroundingSearch>(({ searchQueries, citations }) => {
const { t } = useTranslation('chat');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const [showDetail, setShowDetail] = useState(false);

View file

@ -1,33 +1,15 @@
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
import { LOBE_THEME_APP_ID } from '@lobehub/ui';
import { useLayoutEffect } from 'react';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import { isDesktop } from '@/const/version';
import { useElectronStore } from '@/store/electron';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { ensureElectronIpc } from '@/utils/electron/ipc';
const sidebarColors = {
dark: '#000',
light: '#f8f8f8',
};
export const useWatchThemeUpdate = () => {
const [isAppStateInit, systemAppearance, updateElectronAppState, isMac] = useElectronStore(
(s) => [
s.isAppStateInit,
s.appState.systemAppearance,
s.updateElectronAppState,
s.appState.isMac,
],
);
const [switchThemeMode, switchLocale] = useGlobalStore((s) => [
s.switchThemeMode,
s.switchLocale,
]);
useWatchBroadcast('themeChanged', ({ themeMode }) => {
switchThemeMode(themeMode, { skipBroadcast: true });
});
const [updateElectronAppState] = useElectronStore((s) => [s.updateElectronAppState]);
const [switchLocale] = useGlobalStore((s) => [s.switchLocale]);
useWatchBroadcast('localeChanged', ({ locale }) => {
switchLocale(locale as any, { skipBroadcast: true });
@ -36,20 +18,21 @@ export const useWatchThemeUpdate = () => {
useWatchBroadcast('systemThemeChanged', ({ themeMode }) => {
updateElectronAppState({ systemAppearance: themeMode });
});
const themeMode = useGlobalStore(systemStatusSelectors.themeMode);
useLayoutEffect(() => {
ensureElectronIpc().system.setSystemThemeMode(themeMode);
}, []);
useLayoutEffect(() => {
if (!isAppStateInit || !isMac) return;
document.documentElement.style.background = 'none';
const { theme } = useTheme();
const lobeApp = document.querySelector('#' + LOBE_THEME_APP_ID);
if (!lobeApp) return;
useEffect(() => {
if (!isDesktop) return;
if (!theme) return;
if (systemAppearance) {
document.body.style.background = `color-mix(in srgb, ${sidebarColors[systemAppearance as 'dark' | 'light']} 86%, transparent)`;
}
}, [systemAppearance, isAppStateInit, isMac]);
(async () => {
try {
await ensureElectronIpc().system.updateThemeModeHandler(
theme as 'dark' | 'light' | 'system',
);
} catch {
// Ignore errors in non-electron environment
}
})();
}, [theme]);
};

View file

@ -2,12 +2,13 @@
import { Avatar, Flexbox, Icon, Skeleton, Text, Tooltip } from '@lobehub/ui';
import { Switch } from 'antd';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Bot, Loader2 } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { DEFAULT_AVATAR } from '@/const/meta';
import { useIsDark } from '@/hooks/useIsDark';
import { type LobeAgentSession } from '@/types/session';
const styles = createStaticStyles(({ css }) => ({
@ -59,7 +60,7 @@ interface AgentCardProps {
const AgentCard = memo<AgentCardProps>(
({ agent, inGroup, isHost, loading, onAction, operationLoading }) => {
const { t } = useTranslation('setting');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
if (loading) {
return (

View file

@ -2,12 +2,13 @@
import { Avatar, Flexbox, Icon, Text, Tooltip } from '@lobehub/ui';
import { Switch } from 'antd';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Bot, Loader2 } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { DEFAULT_SUPERVISOR_AVATAR } from '@/const/meta';
import { useIsDark } from '@/hooks/useIsDark';
const styles = createStaticStyles(({ css }) => ({
container: css`
@ -53,7 +54,7 @@ interface HostMemberCardProps {
}
const HostMemberCard = memo<HostMemberCardProps>(({ checked, loading, onToggle }) => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const { t } = useTranslation('setting');
const title = t('settingGroupMembers.host.title');

View file

@ -3,11 +3,13 @@
import { DiffAction, LITEXML_DIFFNODE_ALL_COMMAND } from '@lobehub/editor';
import { Block, Icon } from '@lobehub/ui';
import { Button, Space } from 'antd';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { Check, X } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useIsDark } from '@/hooks/useIsDark';
import { usePageEditorStore } from './store';
const styles = createStaticStyles(({ css }) => ({
@ -36,7 +38,7 @@ const styles = createStaticStyles(({ css }) => ({
const DiffAllToolbar = memo(() => {
const { t } = useTranslation('editor');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const [editor, performSave] = usePageEditorStore((s) => [s.editor, s.performSave]);
const [hasPendingDiffs, setHasPendingDiffs] = useState(false);

View file

@ -1,65 +1,54 @@
import { ActionIcon, Dropdown, type DropdownProps, Icon } from '@lobehub/ui';
import { ActionIcon, DropdownMenu, type DropdownMenuProps, Icon } from '@lobehub/ui';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme as useNextThemesTheme } from 'next-themes';
import { FC, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { type MenuProps } from '@/components/Menu';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
const themeIcons = {
auto: Monitor,
dark: Moon,
light: Sun,
system: Monitor,
};
const ThemeButton: FC<{ placement?: DropdownProps['placement']; size?: number }> = ({
const ThemeButton: FC<{ placement?: DropdownMenuProps['placement']; size?: number }> = ({
placement,
size,
}) => {
const [themeMode, switchThemeMode] = useGlobalStore((s) => [
systemStatusSelectors.themeMode(s),
s.switchThemeMode,
]);
const { setTheme, theme } = useNextThemesTheme();
const { t } = useTranslation('setting');
const items: MenuProps['items'] = useMemo(
const items = useMemo<DropdownMenuProps['items']>(
() => [
{
icon: <Icon icon={themeIcons.auto} />,
key: 'auto',
icon: <Icon icon={themeIcons.system} />,
key: 'system',
label: t('settingCommon.themeMode.auto'),
onClick: () => switchThemeMode('auto'),
onClick: () => setTheme('system'),
},
{
icon: <Icon icon={themeIcons.light} />,
key: 'light',
label: t('settingCommon.themeMode.light'),
onClick: () => switchThemeMode('light'),
onClick: () => setTheme('light'),
},
{
icon: <Icon icon={themeIcons.dark} />,
key: 'dark',
label: t('settingCommon.themeMode.dark'),
onClick: () => switchThemeMode('dark'),
onClick: () => setTheme('dark'),
},
],
[t],
[setTheme, t],
);
return (
<Dropdown
arrow={false}
menu={{
items,
selectable: true,
selectedKeys: [themeMode],
}}
placement={placement}
>
<ActionIcon icon={themeIcons[themeMode]} size={size || { blockSize: 32, size: 16 }} />
</Dropdown>
<DropdownMenu items={items} placement={placement}>
<ActionIcon
icon={themeIcons[(theme as 'dark' | 'light' | 'system') || 'system']}
size={size || { blockSize: 32, size: 16 }}
/>
</DropdownMenu>
);
};

11
src/hooks/useIsDark.ts Normal file
View file

@ -0,0 +1,11 @@
import { useTheme as useNextThemesTheme } from 'next-themes';
/**
* Hook to check if the current theme is dark
* @returns boolean - true if current theme is dark, false otherwise
*/
export const useIsDark = (): boolean => {
const { resolvedTheme } = useNextThemesTheme();
return resolvedTheme === 'dark';
};

View file

@ -3,7 +3,9 @@
import { dark } from '@clerk/themes';
import { type ElementsConfig, type Theme } from '@clerk/types';
import { BRANDING_URL } from '@lobechat/business-const';
import { createStaticStyles, cssVar, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cssVar, cx } from 'antd-style';
import { useIsDark } from '@/hooks/useIsDark';
const prefixCls = 'cl';
@ -95,7 +97,7 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
})) as Partial<Record<keyof ElementsConfig, any>>;
export const useAppearance = () => {
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const navbarStyle = cx(styles.navbar, isDarkMode && (styles as any).navbar_dark);
const scrollBoxStyle = cx(styles.scrollBox, isDarkMode && (styles as any).scrollBox_dark);

View file

@ -2,12 +2,13 @@
import { BRANDING_NAME } from '@lobechat/business-const';
import { Block, Modal, Text } from '@lobehub/ui';
import { createStaticStyles, cx, useThemeMode } from 'antd-style';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PRIVACY_URL, TERMS_URL } from '@/const/url';
import AuthCard from '@/features/AuthCard';
import { useIsDark } from '@/hooks/useIsDark';
const styles = createStaticStyles(({ css }) => ({
container: css`
@ -34,7 +35,7 @@ interface MarketAuthConfirmModalProps {
const MarketAuthConfirmModal = memo<MarketAuthConfirmModalProps>(
({ open, onConfirm, onCancel }) => {
const { t } = useTranslation('marketAuth');
const { isDarkMode } = useThemeMode();
const isDarkMode = useIsDark();
const footer = (
<Text align={'center'} as={'div'} fontSize={13} type={'secondary'}>

View file

@ -8,7 +8,7 @@ import {
ThemeProvider,
} from '@lobehub/ui';
import { message as antdMessage } from 'antd';
import { type ThemeAppearance, createStaticStyles, cx, useTheme } from 'antd-style';
import { createStaticStyles, cx, useTheme } from 'antd-style';
import 'antd/dist/reset.css';
import { AppConfigContext } from 'antd/es/app/context';
import * as motion from 'motion/react-m';
@ -17,13 +17,10 @@ import Link from 'next/link';
import { type ReactNode, memo, useEffect, useMemo, useState } from 'react';
import AntdStaticMethods from '@/components/AntdStaticMethods';
import {
LOBE_THEME_APPEARANCE,
LOBE_THEME_NEUTRAL_COLOR,
LOBE_THEME_PRIMARY_COLOR,
} from '@/const/theme';
import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/theme';
import { isDesktop } from '@/const/version';
import { TITLE_BAR_HEIGHT } from '@/features/ElectronTitlebar';
import { useIsDark } from '@/hooks/useIsDark';
import { getUILocaleAndResources } from '@/libs/getUILocaleAndResources';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
@ -92,7 +89,6 @@ export interface AppThemeProps {
children?: ReactNode;
customFontFamily?: string;
customFontURL?: string;
defaultAppearance?: ThemeAppearance;
defaultNeutralColor?: NeutralColors;
defaultPrimaryColor?: PrimaryColors;
globalCDN?: boolean;
@ -101,16 +97,16 @@ export interface AppThemeProps {
const AppTheme = memo<AppThemeProps>(
({
children,
defaultAppearance,
defaultPrimaryColor,
defaultNeutralColor,
globalCDN,
customFontURL,
customFontFamily,
}) => {
const themeMode = useGlobalStore(systemStatusSelectors.themeMode);
const language = useGlobalStore(systemStatusSelectors.language);
const theme = useTheme();
const antdTheme = useTheme();
const isDark = useIsDark();
const [primaryColor, neutralColor, animationMode] = useUserStore((s) => [
userGeneralSettingsSelectors.primaryColor(s),
userGeneralSettingsSelectors.neutralColor(s),
@ -154,29 +150,29 @@ const AppTheme = memo<AppThemeProps>(
antdMessage.config({ top: messageTop });
}, [messageTop]);
const currentAppearence = isDark ? 'dark' : 'light';
return (
<AppConfigContext.Provider value={appConfig}>
<ThemeProvider
appearance={themeMode !== 'auto' ? themeMode : undefined}
appearance={currentAppearence}
className={cx(styles.app, styles.scrollbar, styles.scrollbarPolyfill)}
customTheme={{
neutralColor: neutralColor ?? defaultNeutralColor,
primaryColor: primaryColor ?? defaultPrimaryColor,
}}
defaultAppearance={defaultAppearance}
onAppearanceChange={(appearance) => {
if (themeMode !== 'auto') return;
setCookie(LOBE_THEME_APPEARANCE, appearance);
}}
defaultAppearance={currentAppearence}
defaultThemeMode={currentAppearence}
theme={{
cssVar: { key: 'lobe-vars' },
token: {
fontFamily: customFontFamily ? `${customFontFamily},${theme.fontFamily}` : undefined,
fontFamily: customFontFamily
? `${customFontFamily},${antdTheme.fontFamily}`
: undefined,
motion: animationMode !== 'disabled',
motionUnit: animationMode === 'agile' ? 0.05 : 0.1,
},
}}
themeMode={themeMode}
>
{!!customFontURL && <FontLoader url={customFontURL} />}
<GlobalStyle />

View file

@ -0,0 +1,22 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ReactNode } from 'react';
interface NextThemeProviderProps {
children: ReactNode;
}
export default function NextThemeProvider({ children }: NextThemeProviderProps) {
return (
<NextThemesProvider
attribute="data-theme"
defaultTheme="system"
disableTransitionOnChange
enableSystem
forcedTheme={undefined}
>
{children}
</NextThemesProvider>
);
}

View file

@ -1,24 +1,29 @@
'use client';
import { StyleProvider, extractStaticStyle } from 'antd-style';
import { StyleProvider } from 'antd-style';
import { useServerInsertedHTML } from 'next/navigation';
import { type PropsWithChildren, useRef } from 'react';
import { type PropsWithChildren } from 'react';
import { isDesktop } from '@/const/version';
const StyleRegistry = ({ children }: PropsWithChildren) => {
const isInsert = useRef(false);
useServerInsertedHTML(() => {
// avoid duplicate css insert
// refs: https://github.com/vercel/next.js/discussions/49354#discussioncomment-6279917
if (isInsert.current) return;
isInsert.current = true;
// @ts-ignore
return extractStaticStyle().map((item) => item.style);
return (
<style
dangerouslySetInnerHTML={{
__html: `
html body {background: #f8f8f8;}
html[data-theme="dark"] body { background-color: #000; }
${isDesktop ? 'html body, html { background: none; }' : ''}
${isDesktop ? 'html[data-theme="dark"] body { background: color-mix(in srgb, #000 86%, transparent); }' : ''}
${isDesktop ? 'html[data-theme="light"] body { background: color-mix(in srgb, #f8f8f8 86%, transparent); }' : ''}
`,
}}
/>
);
});
return <StyleProvider cache={extractStaticStyle.cache}>{children}</StyleProvider>;
return <StyleProvider>{children}</StyleProvider>;
};
export default StyleRegistry;

View file

@ -17,12 +17,12 @@ import AppTheme from './AppTheme';
import { GroupWizardProvider } from './GroupWizardProvider';
import ImportSettings from './ImportSettings';
import Locale from './Locale';
import NextThemeProvider from './NextThemeProvider';
import QueryProvider from './Query';
import StoreInitialization from './StoreInitialization';
import StyleRegistry from './StyleRegistry';
interface GlobalLayoutProps {
appearance: string;
children: ReactNode;
isMobile: boolean;
locale: string;
@ -36,7 +36,7 @@ const GlobalLayout = async ({
neutralColor,
primaryColor,
locale: userLocale,
appearance,
isMobile,
variants,
}: GlobalLayoutProps) => {
@ -45,44 +45,46 @@ const GlobalLayout = async ({
// get default feature flags to use with ssr
const serverFeatureFlags = getServerFeatureFlagsValue();
const serverConfig = await getServerGlobalConfig();
return (
<StyleRegistry>
<Locale antdLocale={antdLocale} defaultLang={userLocale}>
<AppTheme
customFontFamily={appEnv.CUSTOM_FONT_FAMILY}
customFontURL={appEnv.CUSTOM_FONT_URL}
defaultAppearance={appearance}
defaultNeutralColor={neutralColor as any}
defaultPrimaryColor={primaryColor as any}
globalCDN={appEnv.CDN_USE_GLOBAL}
>
<ServerConfigStoreProvider
featureFlags={serverFeatureFlags}
isMobile={isMobile}
segmentVariants={variants}
serverConfig={serverConfig}
<NextThemeProvider>
<AppTheme
customFontFamily={appEnv.CUSTOM_FONT_FAMILY}
customFontURL={appEnv.CUSTOM_FONT_URL}
defaultNeutralColor={neutralColor as any}
defaultPrimaryColor={primaryColor as any}
globalCDN={appEnv.CDN_USE_GLOBAL}
>
<QueryProvider>
<GroupWizardProvider>
<DragUploadProvider>
<LazyMotion features={domMax}>
<TooltipGroup layoutAnimation={false}>
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
</TooltipGroup>
<ModalHost />
<ContextMenuHost />
</LazyMotion>
</DragUploadProvider>
</GroupWizardProvider>
</QueryProvider>
<StoreInitialization />
<Suspense>
{ENABLE_BUSINESS_FEATURES ? <ReferralProvider /> : null}
<ImportSettings />
{process.env.NODE_ENV === 'development' && <DevPanel />}
</Suspense>
</ServerConfigStoreProvider>
</AppTheme>
<ServerConfigStoreProvider
featureFlags={serverFeatureFlags}
isMobile={isMobile}
segmentVariants={variants}
serverConfig={serverConfig}
>
<QueryProvider>
<GroupWizardProvider>
<DragUploadProvider>
<LazyMotion features={domMax}>
<TooltipGroup layoutAnimation={false}>
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
</TooltipGroup>
<ModalHost />
<ContextMenuHost />
</LazyMotion>
</DragUploadProvider>
</GroupWizardProvider>
</QueryProvider>
<StoreInitialization />
<Suspense>
{ENABLE_BUSINESS_FEATURES ? <ReferralProvider /> : null}
<ImportSettings />
{process.env.NODE_ENV === 'development' && <DevPanel />}
</Suspense>
</ServerConfigStoreProvider>
</AppTheme>
</NextThemeProvider>
</Locale>
</StyleRegistry>
);

View file

@ -1,5 +1,4 @@
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { parseDefaultThemeFromCountry } from '@lobechat/utils/server';
import debug from 'debug';
import { type NextRequest, NextResponse } from 'next/server';
import { UAParser } from 'ua-parser-js';
@ -8,7 +7,6 @@ import urlJoin from 'url-join';
import { auth } from '@/auth';
import { OAUTH_AUTHORIZED } from '@/const/auth';
import { LOBE_LOCALE_COOKIE } from '@/const/locale';
import { LOBE_THEME_APPEARANCE } from '@/const/theme';
import { isDesktop } from '@/const/version';
import { appEnv } from '@/envs/app';
import { authEnv } from '@/envs/auth';
@ -39,10 +37,6 @@ export function defineConfig() {
return NextResponse.next();
}
// 1. Read user preferences from cookies
const theme = (request.cookies.get(LOBE_THEME_APPEARANCE)?.value ||
parseDefaultThemeFromCountry(request)) as 'dark' | 'light';
// locale has three levels
// 1. search params
// 2. cookie
@ -67,17 +61,14 @@ export function defineConfig() {
deviceType: device.type,
hasCookies: {
locale: !!request.cookies.get(LOBE_LOCALE_COOKIE)?.value,
theme: !!request.cookies.get(LOBE_THEME_APPEARANCE)?.value,
},
locale,
theme,
});
// 2. Create normalized preference values
const route = RouteVariants.serializeVariants({
isMobile: device.type === 'mobile',
locale,
theme,
});
logDefault('Serialized route variant: %s', route);
@ -99,8 +90,8 @@ export function defineConfig() {
// refs: https://github.com/lobehub/lobe-chat/pull/5866
// new handle segment rewrite: /${route}${originalPathname}
// / -> /zh-CN__0__dark
// /discover -> /zh-CN__0__dark/discover
// / -> /zh-CN__0
// /discover -> /zh-CN__0/discover
// All SPA routes that use react-router-dom should be rewritten to just /${route}
const spaRoutes = [
'/chat',

View file

@ -408,19 +408,4 @@ describe('createPreferenceSlice', () => {
expect(result.current.status.noWideScreen).toEqual(false);
});
});
describe('switchThemeMode', () => {
it('should switch theme mode', async () => {
const { result } = renderHook(() => useGlobalStore());
// Perform the action
act(() => {
useGlobalStore.setState({ isStatusInit: true });
result.current.switchThemeMode('light');
});
// Assert that updateUserSettings was called with the correct theme mode
expect(result.current.status.themeMode).toEqual('light');
});
});
});

View file

@ -1,5 +1,4 @@
import { act, renderHook } from '@testing-library/react';
import { ThemeMode } from 'antd-style';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { withSWR } from '~test-utils';
@ -117,42 +116,6 @@ describe('generalActionSlice', () => {
});
});
describe('switchThemeMode', () => {
it('should update theme mode in system status', () => {
const { result } = renderHook(() => useGlobalStore());
const themeMode: ThemeMode = 'dark';
act(() => {
useGlobalStore.setState({ isStatusInit: true });
result.current.switchThemeMode(themeMode);
});
expect(result.current.status.themeMode).toBe(themeMode);
});
it('should not update theme mode if status is not initialized', () => {
const { result } = renderHook(() => useGlobalStore());
const themeMode: ThemeMode = 'dark';
act(() => {
result.current.switchThemeMode(themeMode);
});
expect(result.current.status.themeMode).toBe(initialState.status.themeMode);
});
it('should handle light theme mode', () => {
const { result } = renderHook(() => useGlobalStore());
act(() => {
useGlobalStore.setState({ isStatusInit: true });
result.current.switchThemeMode('light');
});
expect(result.current.status.themeMode).toBe('light');
});
});
describe('useInitSystemStatus', () => {
it('should reset transient UI states when loading from localStorage', async () => {
const mockStatus = {

View file

@ -1,16 +1,13 @@
import { type ThemeMode } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { gt, parse, valid } from 'semver';
import { type SWRResponse } from 'swr';
import type { StateCreator } from 'zustand/vanilla';
import { LOBE_THEME_APPEARANCE } from '@/const/theme';
import { CURRENT_VERSION, isDesktop } from '@/const/version';
import { useOnlyFetchOnceSWR } from '@/libs/swr';
import { globalService } from '@/services/global';
import type { SystemStatus } from '@/store/global/initialState';
import { type LocaleMode } from '@/types/locale';
import { setCookie } from '@/utils/client/cookie';
import { switchLang } from '@/utils/client/switchLang';
import { merge } from '@/utils/merge';
import { setNamespace } from '@/utils/storeDebug';
@ -23,7 +20,6 @@ export interface GlobalGeneralAction {
openAgentInNewWindow: (agentId: string) => Promise<void>;
openTopicInNewWindow: (agentId: string, topicId: string) => Promise<void>;
switchLocale: (locale: LocaleMode, params?: { skipBroadcast?: boolean }) => void;
switchThemeMode: (themeMode: ThemeMode, params?: { skipBroadcast?: boolean }) => void;
updateSystemStatus: (status: Partial<SystemStatus>, action?: any) => void;
useCheckLatestVersion: (enabledCheck?: boolean) => SWRResponse<string>;
useInitSystemStatus: () => SWRResponse;
@ -114,23 +110,6 @@ export const generalActionSlice: StateCreator<
})();
}
},
switchThemeMode: (themeMode, { skipBroadcast } = {}) => {
get().updateSystemStatus({ themeMode });
setCookie(LOBE_THEME_APPEARANCE, themeMode === 'auto' ? undefined : themeMode);
if (isDesktop && !skipBroadcast) {
(async () => {
try {
const { ensureElectronIpc } = await import('@/utils/electron/ipc');
await ensureElectronIpc().system.updateThemeModeHandler(themeMode);
} catch (error) {
console.error('Failed to update theme in main process:', error);
}
})();
}
},
updateSystemStatus: (status, action) => {
if (!get().isStatusInit) return;

View file

@ -1,4 +1,3 @@
import type { ThemeMode } from 'antd-style';
import type { NavigateFunction } from 'react-router-dom';
import { DatabaseLoadingState, type MigrationSQL, type MigrationTableItem } from '@/types/clientDB';
@ -129,10 +128,6 @@ export interface SystemStatus {
showRightPanel?: boolean;
showSystemRole?: boolean;
systemRoleExpandedMap: Record<string, boolean>;
/**
* theme mode
*/
themeMode?: ThemeMode;
/**
* 使 token
*/
@ -196,7 +191,6 @@ export const INITIAL_STATUS = {
showRightPanel: true,
showSystemRole: false,
systemRoleExpandedMap: {},
themeMode: 'auto',
tokenDisplayFormatShort: true,
topicPageSize: 20,
zenMode: false,

View file

@ -87,24 +87,4 @@ describe('systemStatusSelectors', () => {
expect(systemStatusSelectors.portalWidth(noPortalWidth)).toBe(400);
});
});
describe('theme mode', () => {
it('should return the correct theme', () => {
const s: GlobalState = merge(initialState, {
status: {
themeMode: 'light',
},
});
expect(systemStatusSelectors.themeMode(s)).toBe('light');
});
it('should return auto if not set', () => {
const s: GlobalState = merge(initialState, {
status: {
themeMode: undefined,
},
});
expect(systemStatusSelectors.themeMode(s)).toBe('auto');
});
});
});

View file

@ -23,7 +23,6 @@ const showImagePanel = (s: GlobalState) => s.status.showImagePanel;
const showImageTopicPanel = (s: GlobalState) => s.status.showImageTopicPanel;
const hidePWAInstaller = (s: GlobalState) => s.status.hidePWAInstaller;
const isShowCredit = (s: GlobalState) => s.status.isShowCredit;
const themeMode = (s: GlobalState) => s.status.themeMode || 'auto';
const language = (s: GlobalState) => s.status.language || 'auto';
const showChatHeader = (s: GlobalState) => !s.status.zenMode;
@ -80,7 +79,6 @@ export const systemStatusSelectors = {
showRightPanel,
showSystemRole,
systemStatus,
themeMode,
tokenDisplayFormatShort,
topicGroupKeys,
topicPageSize,

View file

@ -16,8 +16,6 @@ export default ({ token }: { prefixCls: string; token: Theme }) => css`
min-height: 100dvh;
max-height: 100dvh;
background: ${token.colorBgLayout};
@media (min-device-width: 576px) {
overflow: hidden;
}

View file

@ -11,7 +11,6 @@ describe('RouteVariants', () => {
expect(DEFAULT_VARIANTS).toEqual({
isMobile: false,
locale: DEFAULT_LANG,
theme: 'light',
});
});
});
@ -21,30 +20,27 @@ describe('RouteVariants', () => {
const variants: IRouteVariants = {
isMobile: false,
locale: 'en-US',
theme: 'light',
};
const result = RouteVariants.serializeVariants(variants);
expect(result).toBe('en-US__0__light');
expect(result).toBe('en-US__0');
});
it('should serialize variants with mobile enabled', () => {
const variants: IRouteVariants = {
isMobile: true,
locale: 'zh-CN',
theme: 'dark',
};
const result = RouteVariants.serializeVariants(variants);
expect(result).toBe('zh-CN__1__dark');
expect(result).toBe('zh-CN__1');
});
it('should serialize variants with different locales', () => {
const variants: IRouteVariants = {
isMobile: false,
locale: 'ja-JP',
theme: 'light',
};
const result = RouteVariants.serializeVariants(variants);
expect(result).toBe('ja-JP__0__light');
expect(result).toBe('ja-JP__0');
});
it('should serialize variants with custom colors', () => {
@ -53,31 +49,28 @@ describe('RouteVariants', () => {
locale: 'en-US',
neutralColor: '#cccccc',
primaryColor: '#ff0000',
theme: 'dark',
};
const result = RouteVariants.serializeVariants(variants);
expect(result).toBe('en-US__1__dark');
expect(result).toBe('en-US__1');
});
});
describe('deserializeVariants', () => {
it('should deserialize valid serialized string', () => {
const serialized = 'en-US__0__light';
const serialized = 'en-US__0';
const result = RouteVariants.deserializeVariants(serialized);
expect(result).toEqual({
isMobile: false,
locale: 'en-US',
theme: 'light',
});
});
it('should deserialize mobile variants', () => {
const serialized = 'zh-CN__1__dark';
const serialized = 'zh-CN__1';
const result = RouteVariants.deserializeVariants(serialized);
expect(result).toEqual({
isMobile: true,
locale: 'zh-CN',
theme: 'dark',
});
});
@ -94,22 +87,11 @@ describe('RouteVariants', () => {
});
it('should handle invalid locale by falling back to default', () => {
const serialized = 'invalid-locale__0__light';
const serialized = 'invalid-locale__0';
const result = RouteVariants.deserializeVariants(serialized);
expect(result).toEqual({
isMobile: false,
locale: DEFAULT_LANG,
theme: 'light',
});
});
it('should handle invalid theme by falling back to default', () => {
const serialized = 'en-US__0__invalid-theme';
const result = RouteVariants.deserializeVariants(serialized);
expect(result).toEqual({
isMobile: false,
locale: 'en-US',
theme: 'light',
});
});
@ -120,19 +102,19 @@ describe('RouteVariants', () => {
});
it('should handle isMobile value correctly for "0"', () => {
const serialized = 'en-US__0__dark';
const serialized = 'en-US__0';
const result = RouteVariants.deserializeVariants(serialized);
expect(result.isMobile).toBe(false);
});
it('should handle isMobile value correctly for "1"', () => {
const serialized = 'en-US__1__dark';
const serialized = 'en-US__1';
const result = RouteVariants.deserializeVariants(serialized);
expect(result.isMobile).toBe(true);
});
it('should handle isMobile value correctly for other values', () => {
const serialized = 'en-US__2__dark';
const serialized = 'en-US__2';
const result = RouteVariants.deserializeVariants(serialized);
expect(result.isMobile).toBe(false);
});
@ -141,25 +123,23 @@ describe('RouteVariants', () => {
describe('getVariantsFromProps', () => {
it('should extract and deserialize variants from props', async () => {
const props: DynamicLayoutProps = {
params: Promise.resolve({ variants: 'en-US__0__light' }),
params: Promise.resolve({ variants: 'en-US__0' }),
};
const result = await RouteVariants.getVariantsFromProps(props);
expect(result).toEqual({
isMobile: false,
locale: 'en-US',
theme: 'light',
});
});
it('should handle mobile variants from props', async () => {
const props: DynamicLayoutProps = {
params: Promise.resolve({ variants: 'zh-CN__1__dark' }),
params: Promise.resolve({ variants: 'zh-CN__1' }),
};
const result = await RouteVariants.getVariantsFromProps(props);
expect(result).toEqual({
isMobile: true,
locale: 'zh-CN',
theme: 'dark',
});
});
@ -175,7 +155,7 @@ describe('RouteVariants', () => {
describe('getIsMobile', () => {
it('should extract isMobile as false from props', async () => {
const props: DynamicLayoutProps = {
params: Promise.resolve({ variants: 'en-US__0__light' }),
params: Promise.resolve({ variants: 'en-US__0' }),
};
const result = await RouteVariants.getIsMobile(props);
expect(result).toBe(false);
@ -183,7 +163,7 @@ describe('RouteVariants', () => {
it('should extract isMobile as true from props', async () => {
const props: DynamicLayoutProps = {
params: Promise.resolve({ variants: 'en-US__1__dark' }),
params: Promise.resolve({ variants: 'en-US__1' }),
};
const result = await RouteVariants.getIsMobile(props);
expect(result).toBe(true);
@ -201,7 +181,7 @@ describe('RouteVariants', () => {
describe('getLocale', () => {
it('should extract locale from props', async () => {
const props: DynamicLayoutProps = {
params: Promise.resolve({ variants: 'zh-CN__0__light' }),
params: Promise.resolve({ variants: 'zh-CN__0' }),
};
const result = await RouteVariants.getLocale(props);
expect(result).toBe('zh-CN');
@ -209,7 +189,7 @@ describe('RouteVariants', () => {
it('should extract different locale from props', async () => {
const props: DynamicLayoutProps = {
params: Promise.resolve({ variants: 'ja-JP__1__dark' }),
params: Promise.resolve({ variants: 'ja-JP__1' }),
};
const result = await RouteVariants.getLocale(props);
expect(result).toBe('ja-JP');
@ -225,7 +205,7 @@ describe('RouteVariants', () => {
it('should return default locale for invalid locale in props', async () => {
const props: DynamicLayoutProps = {
params: Promise.resolve({ variants: 'invalid-locale__0__light' }),
params: Promise.resolve({ variants: 'invalid-locale__0' }),
};
const result = await RouteVariants.getLocale(props);
expect(result).toBe(DEFAULT_LANG);
@ -254,24 +234,14 @@ describe('RouteVariants', () => {
});
});
it('should create variants with custom theme', () => {
const result = RouteVariants.createVariants({ theme: 'dark' });
expect(result).toEqual({
...DEFAULT_VARIANTS,
theme: 'dark',
});
});
it('should create variants with multiple custom options', () => {
const result = RouteVariants.createVariants({
isMobile: true,
locale: 'ja-JP',
theme: 'dark',
});
expect(result).toEqual({
isMobile: true,
locale: 'ja-JP',
theme: 'dark',
});
});
@ -293,14 +263,12 @@ describe('RouteVariants', () => {
locale: 'zh-CN',
neutralColor: '#aaaaaa',
primaryColor: '#00ff00',
theme: 'dark',
});
expect(result).toEqual({
isMobile: true,
locale: 'zh-CN',
neutralColor: '#aaaaaa',
primaryColor: '#00ff00',
theme: 'dark',
});
});
});
@ -310,7 +278,6 @@ describe('RouteVariants', () => {
const original: IRouteVariants = {
isMobile: true,
locale: 'zh-CN',
theme: 'dark',
};
const serialized = RouteVariants.serializeVariants(original);
const deserialized = RouteVariants.deserializeVariants(serialized);
@ -329,7 +296,6 @@ describe('RouteVariants', () => {
const original: IRouteVariants = {
isMobile: false,
locale: locale as any,
theme: 'light',
};
const serialized = RouteVariants.serializeVariants(original);
const deserialized = RouteVariants.deserializeVariants(serialized);

View file

@ -3,7 +3,6 @@ import { RouteVariants } from '@lobechat/desktop-bridge';
import { type DynamicLayoutProps } from '@/types/next';
export { LOBE_LOCALE_COOKIE } from '@/const/locale';
export { LOBE_THEME_APPEARANCE } from '@/const/theme';
export {
DEFAULT_LANG,
DEFAULT_VARIANTS,