Skip to content
Open
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
15,432 changes: 15,432 additions & 0 deletions web/package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"@types/react-syntax-highlighter": "^15.5.1",
"@typescript-eslint/eslint-plugin": "^6",
"@typescript-eslint/parser": "^6",
"ajv": "^8.20.0",
"ajv-keywords": "^5.1.0",
Comment on lines +38 to +39
"babel-loader": "^9.1.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^7.1.2",
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import logo from "~~/assets/logo.svg";
import NavLink from "./NavLink";
import { IconOpenNewTab, LogoImage } from "./styles";
import useAuth from "~/contexts/auth-context/use-auth";
import ThemeToggle from "~/components/theme/ThemeToggle";

export const APP_HEADER_HEIGHT = 56;

Expand Down Expand Up @@ -110,6 +111,7 @@ export const Header: FC = memo(function Header() {
alignItems: "center",
}}
>
<ThemeToggle />
{me?.isLogin ? (
<>
<NavLink href={PAGE_PATH_APPLICATIONS}>Applications</NavLink>
Expand Down
22 changes: 22 additions & 0 deletions web/src/components/theme/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from "react";
import { IconButton, Tooltip } from "@mui/material";
import { DarkMode, LightMode } from "@mui/icons-material";
import { useTheme } from "../../contexts/ThemeContext";

/**
* ThemeToggle component for switching between light and dark modes
* Can be placed in the header or navigation
*/
export const ThemeToggle: React.FC = () => {
const { mode, toggleTheme } = useTheme();

return (
<Tooltip title={`Switch to ${mode === "light" ? "dark" : "light"} mode`}>
<IconButton onClick={toggleTheme} size="small" color="inherit">
{mode === "light" ? <DarkMode /> : <LightMode />}
</IconButton>
</Tooltip>
);
};

export default ThemeToggle;
107 changes: 107 additions & 0 deletions web/src/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import { getTheme } from "../theme";

type ThemeMode = "light" | "dark";

interface ThemeContextType {
mode: ThemeMode;
toggleTheme: () => void;
setTheme: (mode: ThemeMode) => void;
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

const THEME_STORAGE_KEY = "pipecd-theme-mode";

/**
* Detects the system's preferred theme mode
*/
const getSystemThemeMode = (): ThemeMode => {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};

interface ThemeProviderProps {
children: ReactNode;
defaultMode?: ThemeMode;
}

export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children,
defaultMode,
}) => {
const [mode, setMode] = useState<ThemeMode>(() => {
// Try to load from localStorage
if (typeof window !== "undefined") {
const stored = localStorage.getItem(THEME_STORAGE_KEY) as ThemeMode | null;
if (stored && (stored === "light" || stored === "dark")) {
return stored;
}
}
// Fall back to default or system preference
return defaultMode || getSystemThemeMode();
});

// Persist theme mode to localStorage
useEffect(() => {
localStorage.setItem(THEME_STORAGE_KEY, mode);
// Update HTML attribute for CSS-based styling if needed
if (typeof window !== "undefined") {
document.documentElement.setAttribute("data-theme", mode);
}
}, [mode]);

// Listen to system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
// Only update if user hasn't explicitly set a preference
if (!stored) {
setMode(e.matches ? "dark" : "light");
}
};
Comment on lines +49 to +67

mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
Comment on lines +59 to +71

const toggleTheme = () => {
setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
};

const currentTheme = getTheme(mode);

const value: ThemeContextType = {
mode,
toggleTheme,
setTheme: setMode,
};

return (
<ThemeContext.Provider value={value}>
<MuiThemeProvider theme={currentTheme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>
);
};

/**
* Hook to access theme context
* @throws Error if used outside ThemeProvider
*/
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
Comment on lines +99 to +102
}
return context;
};

export default ThemeProvider;
8 changes: 3 additions & 5 deletions web/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ThemeProvider, StyledEngineProvider } from "@mui/material";
import CssBaseline from "@mui/material/CssBaseline";
import { StyledEngineProvider } from "@mui/material";
import { render } from "react-dom";
import { theme } from "./theme";
import { Routes } from "./routes";
import { BrowserRouter } from "react-router-dom";
import { setupDayjs } from "./utils/setup-dayjs";
Expand All @@ -10,6 +8,7 @@ import QueryClientWrap from "./contexts/query-client-provider";
import { AuthProvider } from "./contexts/auth-context";
import { ToastProvider } from "./contexts/toast-context/toast-provider";
import { CommandProvider } from "./contexts/command-context";
import { ThemeProvider } from "./contexts/ThemeContext";

async function run(): Promise<void> {
if (process.env.ENABLE_MOCK === "true") {
Expand Down Expand Up @@ -48,7 +47,7 @@ Happy PipeCD-ing 🙌
render(
<CookiesProvider>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<ThemeProvider>
<BrowserRouter
future={{
v7_startTransition: false,
Expand All @@ -59,7 +58,6 @@ Happy PipeCD-ing 🙌
<QueryClientWrap>
<AuthProvider>
<CommandProvider>
<CssBaseline />
<Routes />
</CommandProvider>
</AuthProvider>
Expand Down
128 changes: 89 additions & 39 deletions web/src/theme.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createTheme } from "@mui/material/styles";
import { createTheme, Theme } from "@mui/material/styles";
import { cyan } from "@mui/material/colors";

declare module "@mui/material/styles/createTypography" {
Expand All @@ -11,53 +11,58 @@ declare module "@mui/material/styles/createTypography" {
}
}

export const theme = createTheme({
components: {
MuiButtonBase: {
defaultProps: {
disableRipple: true,
},
const fontFamilyMono =
'"Roboto Mono",SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace';

const commonComponents = {
MuiButtonBase: {
defaultProps: {
disableRipple: true,
},
MuiTypography: {
defaultProps: {
variantMapping: {
body1: "div",
body2: "div",
},
},
MuiTypography: {
defaultProps: {
variantMapping: {
body1: "div",
body2: "div",
},
},
MuiCssBaseline: {
styleOverrides: {
html: {
height: "100%",
},
body: {
height: "100%",
},
"#root": {
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
},
},
MuiCssBaseline: {
styleOverrides: {
html: {
height: "100%",
},
body: {
height: "100%",
},
"#root": {
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 16,
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 16,
},
},
MuiDialogActions: {
styleOverrides: {
spacing: {
padding: 16,
},
},
MuiDialogActions: {
styleOverrides: {
spacing: {
padding: 16,
},
},
},
};

export const lightTheme = createTheme({
palette: {
mode: "light",
primary: { main: "#283778" },
success: {
main: "#539d56",
Expand All @@ -72,13 +77,58 @@ export const theme = createTheme({
secondary: cyan,
background: {
default: "#fafafa",
paper: "#ffffff",
},
text: {
primary: "rgba(0, 0, 0, 0.87)",
secondary: "rgba(0, 0, 0, 0.6)",
},
},
components: commonComponents,
typography: {
subtitle2: {
fontWeight: 600,
},
fontFamilyMono:
'"Roboto Mono",SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace',
fontFamilyMono,
},
});

export const darkTheme = createTheme({
palette: {
mode: "dark",
primary: { main: "#6fa3ff" },
success: {
main: "#66bb6a",
light: "#81c784",
dark: "#2e7d32",
},
error: {
main: "#ef5350",
light: "#e57373",
dark: "#c62828",
},
secondary: cyan,
background: {
default: "#121212",
paper: "#1e1e1e",
},
text: {
primary: "#ffffff",
secondary: "rgba(255, 255, 255, 0.7)",
},
},
components: commonComponents,
typography: {
subtitle2: {
fontWeight: 600,
},
fontFamilyMono,
},
});

export const getTheme = (mode: "light" | "dark"): Theme => {
return mode === "dark" ? darkTheme : lightTheme;
};

// Default export for backward compatibility
export const theme = lightTheme;
2 changes: 1 addition & 1 deletion web/webpack.config.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ module.exports = (env) =>
allowedHosts: "all",
proxy: [
{
context: ["/api"],
context: ["/api", "/auth"],
changeOrigin: true,
target: process.env.API_ENDPOINT,
pathRewrite: { "^/api": "" },
Expand Down
Loading
Loading