Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"prepare": "cd .. && husky",
"test": "vitest run",
"test:watch": "vitest"
"prepare": "cd .. && husky"
},
"lint-staged": {
"*.{ts,tsx}": [
Expand Down
24 changes: 5 additions & 19 deletions web/src/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import {
Avatar,
Dropdown,
Input,
ConfigProvider,
theme,
Modal,
} from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
Expand All @@ -49,6 +47,7 @@ import {
Notebook,
} from '@phosphor-icons/react';
import { useLang } from '../i18n/LangContext';
import { useTheme } from '../theme/ThemeContext';

const { Sider, Content } = Layout;

Expand All @@ -57,7 +56,7 @@ const iconSize = 18;
const MainLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const [darkMode, setDarkMode] = useState(false);
const { darkMode, toggleTheme } = useTheme();
const [searchOpen, setSearchOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const { lang, setLang, t } = useLang();
Expand Down Expand Up @@ -152,20 +151,7 @@ const MainLayout = () => {
const logoColor = darkMode ? '#e5e5e5' : '#1b1b1a';

return (
<ConfigProvider
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: darkMode
? {
colorBgBase: '#2a2a2e',
colorBgContainer: '#323236',
colorBgElevated: '#3a3a3e',
colorBorder: '#3a3a3e',
colorBorderSecondary: '#333337',
}
: undefined,
}}
>
<>
<Layout style={{ minHeight: '100vh' }}>
<Sider
theme={darkMode ? 'dark' : 'light'}
Expand Down Expand Up @@ -282,7 +268,7 @@ const MainLayout = () => {

{/* Theme toggle */}
<div
onClick={() => setDarkMode(!darkMode)}
onClick={toggleTheme}
style={{
cursor: 'pointer',
display: 'flex',
Expand Down Expand Up @@ -393,7 +379,7 @@ const MainLayout = () => {
))}
</div>
</Modal>
</ConfigProvider>
</>
);
};

Expand Down
18 changes: 16 additions & 2 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,37 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import { ConfigProvider, theme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
import { LangProvider, useLang } from './i18n/LangContext';
import { ThemeProvider, useTheme } from './theme/ThemeContext';
import App from './App';
import './index.css';

const ThemedApp = () => {
const { lang } = useLang();
const { darkMode } = useTheme();

return (
<ConfigProvider
locale={lang === 'zh' ? zhCN : enUS}
theme={{
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: '#1677ff',
borderRadius: 8,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
...(darkMode
? {
colorBgBase: '#2a2a2e',
colorBgContainer: '#323236',
colorBgElevated: '#3a3a3e',
colorBorder: '#3a3a3e',
colorBorderSecondary: '#333337',
}
: {}),
},
components: {
Card: { borderRadiusLG: 12 },
Expand All @@ -56,7 +68,9 @@ const ThemedApp = () => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<LangProvider>
<ThemedApp />
<ThemeProvider>
<ThemedApp />
</ThemeProvider>
</LangProvider>
</React.StrictMode>,
);
1 change: 1 addition & 0 deletions web/src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="vitest/globals" />
import '@testing-library/jest-dom/vitest';

// Clean up localStorage between tests
beforeEach(() => {
localStorage.clear();
});
68 changes: 68 additions & 0 deletions web/src/theme/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';

interface ThemeContextType {
darkMode: boolean;
setDarkMode: (dark: boolean) => void;
toggleTheme: () => void;
}

const THEME_STORAGE_KEY = 'rocketmq-studio-theme';

const ThemeContext = createContext<ThemeContextType>({
darkMode: false,
setDarkMode: () => {},
toggleTheme: () => {},
});

/** Read initial theme preference from localStorage, fall back to system preference. */
const getInitialDarkMode = (): boolean => {
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored !== null) return stored === 'dark';
} catch {
// localStorage unavailable
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
};

export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [darkMode, setDarkMode] = useState<boolean>(getInitialDarkMode);

// Persist theme choice to localStorage
useEffect(() => {
try {
localStorage.setItem(THEME_STORAGE_KEY, darkMode ? 'dark' : 'light');
} catch {
// localStorage unavailable
}
}, [darkMode]);

const toggleTheme = () => setDarkMode((prev) => !prev);

return (
<ThemeContext.Provider value={{ darkMode, setDarkMode, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};

export const useTheme = () => useContext(ThemeContext);

export default ThemeContext;
156 changes: 156 additions & 0 deletions web/src/theme/__tests__/ThemeContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from '../ThemeContext';

/** Helper component that displays theme state and provides toggle button. */
const ThemeConsumer = () => {
const { darkMode, toggleTheme, setDarkMode } = useTheme();
return (
<div>
<span data-testid="mode">{darkMode ? 'dark' : 'light'}</span>
<button onClick={toggleTheme}>toggle</button>
<button onClick={() => setDarkMode(true)}>set-dark</button>
<button onClick={() => setDarkMode(false)}>set-light</button>
</div>
);
};

describe('ThemeContext', () => {
beforeEach(() => {
localStorage.clear();
});

it('defaults to light mode when no stored preference', () => {
// jsdom does not implement matchMedia – define it to return light preference
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('mode')).toHaveTextContent('light');
});

it('reads dark mode from localStorage', () => {
localStorage.setItem('rocketmq-studio-theme', 'dark');

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
});

it('reads light mode from localStorage', () => {
localStorage.setItem('rocketmq-studio-theme', 'light');

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('mode')).toHaveTextContent('light');
});

it('toggles from light to dark', async () => {
const user = userEvent.setup();

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('mode')).toHaveTextContent('light');

await user.click(screen.getByText('toggle'));
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
});

it('toggles from dark back to light', async () => {
localStorage.setItem('rocketmq-studio-theme', 'dark');
const user = userEvent.setup();

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('mode')).toHaveTextContent('dark');

await user.click(screen.getByText('toggle'));
expect(screen.getByTestId('mode')).toHaveTextContent('light');
});

it('persists theme to localStorage after toggle', async () => {
const user = userEvent.setup();

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);

await user.click(screen.getByText('toggle'));
expect(localStorage.getItem('rocketmq-studio-theme')).toBe('dark');
});

it('setDarkMode(true) switches to dark', async () => {
const user = userEvent.setup();

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
expect(screen.getByTestId('mode')).toHaveTextContent('light');

await user.click(screen.getByText('set-dark'));
expect(screen.getByTestId('mode')).toHaveTextContent('dark');
});

it('setDarkMode(false) switches to light', async () => {
localStorage.setItem('rocketmq-studio-theme', 'dark');
const user = userEvent.setup();

render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);

await user.click(screen.getByText('set-light'));
expect(screen.getByTestId('mode')).toHaveTextContent('light');
});
});
Loading