Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
Binary file added console/public/copaw-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added console/public/dark-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 18 additions & 1 deletion console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import enUS from "antd/locale/en_US";
import jaJP from "antd/locale/ja_JP";
import ruRU from "antd/locale/ru_RU";
import type { Locale } from "antd/es/locale";
import { theme as antdTheme } from "antd";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import "dayjs/locale/ja";
import "dayjs/locale/ru";
import MainLayout from "./layouts/MainLayout";
import { ThemeProvider, useTheme } from "./contexts/ThemeContext";
import "./styles/layout.css";
import "./styles/form-override.css";

Expand All @@ -37,8 +39,9 @@ const GlobalStyle = createGlobalStyle`
}
`;

function App() {
function AppInner() {
const { i18n } = useTranslation();
const { isDark } = useTheme();
const lang = i18n.resolvedLanguage || i18n.language || "en";
const [antdLocale, setAntdLocale] = useState<Locale>(
antdLocaleMap[lang] ?? enUS,
Expand Down Expand Up @@ -68,11 +71,25 @@ function App() {
prefix="copaw"
prefixCls="copaw"
locale={antdLocale}
theme={{
...(bailianTheme as any)?.theme,
algorithm: isDark
? antdTheme.darkAlgorithm
: antdTheme.defaultAlgorithm,
}}
>
<MainLayout />
</ConfigProvider>
</BrowserRouter>
);
}

function App() {
return (
<ThemeProvider>
<AppInner />
</ThemeProvider>
);
}

export default App;
36 changes: 36 additions & 0 deletions console/src/components/ThemeToggleButton/index.module.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* ---- Theme toggle button ---- */
.toggleBtn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
color: #555;
padding: 0;
flex-shrink: 0;

&:hover {
background: rgba(97, 92, 237, 0.08);
color: #615ced;
}
}

.icon {
font-size: 16px;
}

/* Dark mode overrides */
:global(.dark-mode) .toggleBtn {
color: #ccc;

&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
26 changes: 26 additions & 0 deletions console/src/components/ThemeToggleButton/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Tooltip, Button } from "antd";
import { SunOutlined, MoonOutlined } from "@ant-design/icons";
import { useTheme } from "../../contexts/ThemeContext";
import styles from "./index.module.less";

/**
* ThemeToggleButton - toggles between light and dark theme.
* Displays a sun icon in dark mode and a moon icon in light mode.
*/
export default function ThemeToggleButton() {
const { isDark, toggleTheme } = useTheme();

return (
<Tooltip title={isDark ? "Light mode" : "Dark mode"}>
<Button
className={styles.toggleBtn}
onClick={toggleTheme}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
type="text"
icon={isDark ? <SunOutlined /> : <MoonOutlined />}
>
{isDark ? "Light" : "Dark"}
</Button>
</Tooltip>
);
}
104 changes: 104 additions & 0 deletions console/src/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";

export type ThemeMode = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";

const STORAGE_KEY = "copaw-theme";

interface ThemeContextValue {
/** User selected preference: light / dark / system */
themeMode: ThemeMode;
/** Resolved final theme after applying system preference */
isDark: boolean;
setThemeMode: (mode: ThemeMode) => void;
/** Convenience toggle: light ↔ dark (skips system) */
toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue>({
themeMode: "light",
isDark: false,
setThemeMode: () => {},
toggleTheme: () => {},
});

function getInitialMode(): ThemeMode {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
} catch {
// ignore storage errors
}
return "system";
}

function resolveIsDark(mode: ThemeMode): boolean {
if (mode === "dark") return true;
if (mode === "light") return false;
// system
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
}

export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeMode, setThemeModeState] = useState<ThemeMode>(getInitialMode);
const [isDark, setIsDark] = useState<boolean>(() =>
resolveIsDark(getInitialMode()),
);

// Apply dark/light class to <html> element for global CSS variable overrides
useEffect(() => {
const html = document.documentElement;
if (isDark) {
html.classList.add("dark-mode");
} else {
html.classList.remove("dark-mode");
}
}, [isDark]);

// Listen to system theme changes when mode is "system"
useEffect(() => {
if (themeMode !== "system") return;

const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
setIsDark(e.matches);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [themeMode]);

const setThemeMode = useCallback((mode: ThemeMode) => {
setThemeModeState(mode);
setIsDark(resolveIsDark(mode));
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// ignore
}
}, []);

const toggleTheme = useCallback(() => {
setThemeMode(isDark ? "light" : "dark");
}, [isDark, setThemeMode]);

return (
<ThemeContext.Provider
value={{ themeMode, isDark, setThemeMode, toggleTheme }}
>
{children}
</ThemeContext.Provider>
);
}

export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}
2 changes: 2 additions & 0 deletions console/src/layouts/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Layout, Space } from "antd";
import LanguageSwitcher from "../components/LanguageSwitcher";
import ThemeToggleButton from "../components/ThemeToggleButton";
import { useTranslation } from "react-i18next";
import {
FileTextOutlined,
Expand Down Expand Up @@ -101,6 +102,7 @@ export default function Header({ selectedKey }: HeaderProps) {
</Button>
</Tooltip>
<LanguageSwitcher />
<ThemeToggleButton />
</Space>
</AntHeader>
);
Expand Down
11 changes: 9 additions & 2 deletions console/src/layouts/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from "lucide-react";
import api from "../api";
import styles from "./index.module.less";
import { useTheme } from "../contexts/ThemeContext";

const { Sider } = Layout;

Expand Down Expand Up @@ -192,6 +193,7 @@ function CopyButton({ text }: { text: string }) {
export default function Sidebar({ selectedKey }: SidebarProps) {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { isDark } = useTheme();
const [collapsed, setCollapsed] = useState(false);
const [openKeys, setOpenKeys] = useState<string[]>(DEFAULT_OPEN_KEYS);
const [version, setVersion] = useState<string>("");
Expand Down Expand Up @@ -362,12 +364,16 @@ export default function Sidebar({ selectedKey }: SidebarProps) {
collapsed={collapsed}
onCollapse={setCollapsed}
width={275}
className={styles.sider}
className={`${styles.sider}${isDark ? ` ${styles.siderDark}` : ""}`}
>
<div className={styles.siderTop}>
{!collapsed && (
<div className={styles.logoWrapper}>
<img src="/logo.png" alt="CoPaw" className={styles.logoImg} />
<img
src={isDark ? "/dark-logo.png" : "/logo.png"}
alt="CoPaw"
className={styles.logoImg}
/>
{version && (
<Badge dot={!!hasUpdate} color="red" offset={[4, 18]}>
<span
Expand Down Expand Up @@ -408,6 +414,7 @@ export default function Sidebar({ selectedKey }: SidebarProps) {
if (path) navigate(path);
}}
items={menuItems}
theme={isDark ? "dark" : "light"}
/>

<Modal
Expand Down
96 changes: 96 additions & 0 deletions console/src/layouts/index.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,37 @@
}
}

.siderDark {
background: #1a1a1a !important;
border-right-color: rgba(255, 255, 255, 0.08) !important;

:global {
.copaw-menu {
background: #1a1a1a !important;
border-inline-end: 0 !important;
}
/* Group title (sub-menu label) */
.copaw-menu-submenu-title {
color: rgba(255, 255, 255, 0.45) !important;
}
/* Regular menu item */
.copaw-menu-item {
color: rgba(255, 255, 255, 0.75) !important;
}
/* Hover state */
.copaw-menu-item:hover,
.copaw-menu-submenu-title:hover {
background: rgba(255, 255, 255, 0.06) !important;
color: rgba(255, 255, 255, 0.9) !important;
}
/* Selected item */
.copaw-menu-item-selected {
background: rgba(97, 92, 237, 0.2) !important;
color: #8b87f0 !important;
}
}
}

.siderTop {
height: 64px;
display: flex;
Expand Down Expand Up @@ -140,3 +171,68 @@
.copyBtnDefault {
color: #999;
}

/* ─── Dark mode overrides ─────────────────────────────────────────────────── */
:global(.dark-mode) {
.header {
background: #1f1f1f !important;
border-bottom-color: rgba(255, 255, 255, 0.08) !important;
color: rgba(255, 255, 255, 0.85);

/* Text buttons (Docs / FAQ / GitHub / Changelog) */
:global(.copaw-btn-text),
:global(.ant-btn-text) {
color: rgba(255, 255, 255, 0.75) !important;

&:hover {
color: #fff !important;
background: rgba(255, 255, 255, 0.08) !important;
}

.anticon,
svg {
color: inherit !important;
}
}

/* Fallback: any icon or text directly inside header */
:global(.anticon) {
color: rgba(255, 255, 255, 0.75);
}
}

.headerTitle {
color: rgba(255, 255, 255, 0.85);
}

.sider {
background: #1a1a1a !important;
border-right-color: rgba(255, 255, 255, 0.08) !important;
}

.siderTop {
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.versionBadge {
color: #8b87f0;
}

.collapseBtn {
color: #8b87f0;
}

.codeBlock {
background: #2a2a2a;
border-color: rgba(255, 255, 255, 0.1);
}

.codeInline {
background: #2a2a2a;
color: rgba(255, 255, 255, 0.85);
}

.copyBtnDefault {
color: rgba(255, 255, 255, 0.3);
}
}
Loading
Loading