mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
feat(ui): wire up dark mode toggle in dashboard
Centralizes dark mode state in ThemeContext so a single source manages the `dark` class on <html>, persists the preference to localStorage, and wraps children with antd's ConfigProvider (darkAlgorithm). The previously hidden navbar toggle is now rendered and functional. - ThemeContext now exposes isDarkMode / toggleDarkMode and applies the Tailwind `dark` class + antd dark algorithm based on the stored value. - Navbar isDarkMode / toggleDarkMode props are optional and fall back to the context; the toggle is no longer gated behind a `false &&` flag. - Root page and dashboard layout consume the context instead of holding their own local dark mode state / duplicate ConfigProvider. - Tailwind dark variants added to navbar and dashboard shell so the most visible surfaces respect the theme. - globals.css adds `.dark` overrides for the body background tokens. https://claude.ai/code/session_01SkckoL68328QgB5ifQwntT
This commit is contained in:
parent
cff3e0b75e
commit
cf059b94cc
7 changed files with 160 additions and 80 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import Navbar from "@/components/navbar";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ThemeProvider, useTheme } from "@/contexts/ThemeContext";
|
||||
import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
|
@ -34,10 +34,11 @@ const MIGRATED_PAGES: Record<string, string> = {
|
|||
"api-reference": "api-reference",
|
||||
};
|
||||
|
||||
function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized();
|
||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
|
||||
const [page, setPage] = useState(() => {
|
||||
return searchParams.get("page") || "api-keys";
|
||||
|
|
@ -64,34 +65,36 @@ function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||
const toggleSidebar = () => setSidebarCollapsed((v) => !v);
|
||||
|
||||
return (
|
||||
<ThemeProvider accessToken={""}>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar
|
||||
isPublicPage={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
userID={userId}
|
||||
userEmail={userEmail}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
proxySettings={undefined}
|
||||
setProxySettings={() => { }}
|
||||
accessToken={accessToken}
|
||||
isDarkMode={false}
|
||||
toggleDarkMode={() => { }}
|
||||
/>
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
<SidebarProvider
|
||||
setPage={handleSetPage}
|
||||
defaultSelectedKey={page}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
<main className="flex-1">{children}</main>
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<Navbar
|
||||
isPublicPage={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
userID={userId}
|
||||
userEmail={userEmail}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
proxySettings={undefined}
|
||||
setProxySettings={() => {}}
|
||||
accessToken={accessToken}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
/>
|
||||
<DebugWarningBanner accessToken={accessToken} />
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
<SidebarProvider setPage={handleSetPage} defaultSelectedKey={page} sidebarCollapsed={sidebarCollapsed} />
|
||||
</div>
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider accessToken={""}>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@
|
|||
--neutral-border: #dcddeb;
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
} */
|
||||
.dark {
|
||||
--foreground-rgb: 229, 231, 235; /* gray-200 */
|
||||
--background-start-rgb: 17, 24, 39; /* gray-900 */
|
||||
--background-end-rgb: 17, 24, 39;
|
||||
--neutral-border: #374151; /* gray-700 */
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { jwtDecode } from "jwt-decode";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ConfigProvider, theme } from "antd";
|
||||
|
||||
function deleteCookie(name: string, path = "/") {
|
||||
// Best-effort client-side clear (works for non-HttpOnly cookies without Domain)
|
||||
|
|
@ -110,12 +109,6 @@ function CreateKeyPageContent() {
|
|||
const [showClaudeCodePrompt, setShowClaudeCodePrompt] = useState(false);
|
||||
const [showClaudeCodeModal, setShowClaudeCodeModal] = useState(false);
|
||||
|
||||
// Dark mode state
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const toggleDarkMode = () => {
|
||||
setIsDarkMode(!isDarkMode);
|
||||
};
|
||||
|
||||
const invitation_id = searchParams.get("invitation_id");
|
||||
|
||||
// Parse URL query parameters for pre-filling the create key form
|
||||
|
|
@ -450,10 +443,7 @@ function CreateKeyPageContent() {
|
|||
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<ConfigProvider theme={{
|
||||
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}>
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
{invitation_id ? (
|
||||
<UserDashboard
|
||||
userID={userID}
|
||||
|
|
@ -471,7 +461,7 @@ function CreateKeyPageContent() {
|
|||
createClicked={createClicked}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<Navbar
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
|
|
@ -483,8 +473,6 @@ function CreateKeyPageContent() {
|
|||
isPublicPage={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
/>
|
||||
<div className="flex flex-1">
|
||||
<div className="mt-2">
|
||||
|
|
@ -682,7 +670,6 @@ function CreateKeyPageContent() {
|
|||
</div>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,11 @@ vi.mock("./Navbar/CommunityEngagementButtons/CommunityEngagementButtons", () =>
|
|||
}));
|
||||
|
||||
// Create mock functions that can be controlled in tests
|
||||
let mockUseThemeImpl = () => ({ logoUrl: null as string | null });
|
||||
let mockUseThemeImpl = () => ({
|
||||
logoUrl: null as string | null,
|
||||
isDarkMode: false,
|
||||
toggleDarkMode: vi.fn(),
|
||||
});
|
||||
let mockUseHealthReadinessDetailsImpl = () => ({ data: null as any });
|
||||
let mockGetLocalStorageItemImpl = (key: string) => null as string | null;
|
||||
let mockUseAuthorizedImpl = () => ({
|
||||
|
|
@ -236,7 +240,11 @@ describe("Navbar", () => {
|
|||
});
|
||||
|
||||
it("should use custom logo from theme context", () => {
|
||||
mockUseThemeImpl = () => ({ logoUrl: "https://example.com/custom-logo.png" });
|
||||
mockUseThemeImpl = () => ({
|
||||
logoUrl: "https://example.com/custom-logo.png",
|
||||
isDarkMode: false,
|
||||
toggleDarkMode: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
|
|
@ -244,7 +252,7 @@ describe("Navbar", () => {
|
|||
expect(logoImg).toHaveAttribute("src", "https://example.com/custom-logo.png");
|
||||
|
||||
// Reset mock
|
||||
mockUseThemeImpl = () => ({ logoUrl: null });
|
||||
mockUseThemeImpl = () => ({ logoUrl: null, isDarkMode: false, toggleDarkMode: vi.fn() });
|
||||
});
|
||||
|
||||
it("should hide user dropdown on public pages", () => {
|
||||
|
|
@ -304,10 +312,38 @@ describe("Navbar", () => {
|
|||
expect(window.location.href).toBe("");
|
||||
});
|
||||
|
||||
it("should not render dark mode toggle slider", () => {
|
||||
it("should render dark mode toggle", () => {
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
// DO NOT RENDER THIS UNTIL ALL COMPONENTS ARE CONFIRMED TO SUPPORT DARK MODE STYLES. IT IS AN ISSUE IF THIS TEST FAILS.
|
||||
expect(screen.queryByTestId("dark-mode-toggle")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("dark-mode-toggle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call toggleDarkMode when toggle is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggle = vi.fn();
|
||||
renderWithProviders(<Navbar {...defaultProps} isDarkMode={false} toggleDarkMode={mockToggle} />);
|
||||
|
||||
await user.click(screen.getByTestId("dark-mode-toggle"));
|
||||
|
||||
expect(mockToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should fall back to ThemeContext when toggle props are not provided", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ctxToggle = vi.fn();
|
||||
mockUseThemeImpl = () => ({
|
||||
logoUrl: null,
|
||||
isDarkMode: true,
|
||||
toggleDarkMode: ctxToggle,
|
||||
});
|
||||
|
||||
const { isDarkMode, toggleDarkMode, ...rest } = defaultProps;
|
||||
renderWithProviders(<Navbar {...rest} />);
|
||||
|
||||
await user.click(screen.getByTestId("dark-mode-toggle"));
|
||||
expect(ctxToggle).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset mock
|
||||
mockUseThemeImpl = () => ({ logoUrl: null, isDarkMode: false, toggleDarkMode: vi.fn() });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ interface NavbarProps {
|
|||
isPublicPage: boolean;
|
||||
sidebarCollapsed?: boolean;
|
||||
onToggleSidebar?: () => void;
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
/** Optional override; defaults to the value from ThemeContext. */
|
||||
isDarkMode?: boolean;
|
||||
/** Optional override; defaults to the toggler from ThemeContext. */
|
||||
toggleDarkMode?: () => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({
|
||||
|
|
@ -40,12 +42,14 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
isPublicPage = false,
|
||||
sidebarCollapsed = false,
|
||||
onToggleSidebar,
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
isDarkMode: isDarkModeProp,
|
||||
toggleDarkMode: toggleDarkModeProp,
|
||||
}) => {
|
||||
const baseUrl = getProxyBaseUrl();
|
||||
const [logoutUrl, setLogoutUrl] = useState("");
|
||||
const { logoUrl } = useTheme();
|
||||
const { logoUrl, isDarkMode: isDarkModeCtx, toggleDarkMode: toggleDarkModeCtx } = useTheme();
|
||||
const isDarkMode = isDarkModeProp ?? isDarkModeCtx;
|
||||
const toggleDarkMode = toggleDarkModeProp ?? toggleDarkModeCtx;
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
|
|
@ -87,14 +91,14 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
{onToggleSidebar && (
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="flex items-center justify-center w-10 h-10 mr-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
|
||||
className="flex items-center justify-center w-10 h-10 mr-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<span className="text-lg">{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}</span>
|
||||
|
|
@ -142,17 +146,14 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<div className="flex items-center space-x-5 ml-auto">
|
||||
<WorkerDropdown onWorkerSwitch={handleWorkerSwitch} />
|
||||
<CommunityEngagementButtons />
|
||||
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.
|
||||
Do not set this to true by default until all components are confirmed to support dark mode styles. */}
|
||||
{false && (
|
||||
<Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
data-testid="dark-mode-toggle"
|
||||
aria-label="Toggle dark mode"
|
||||
checked={isDarkMode}
|
||||
onChange={toggleDarkMode}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
<Button type="text" href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer">
|
||||
Docs
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -979,7 +979,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
|
||||
return (
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
<div className={isEmbedded ? "w-full" : "min-h-screen bg-white"}>
|
||||
<div className={isEmbedded ? "w-full" : "min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"}>
|
||||
{/* Navigation - only show when not embedded */}
|
||||
{!isEmbedded && (
|
||||
<Navbar
|
||||
|
|
@ -991,8 +991,6 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
proxySettings={proxySettings}
|
||||
accessToken={accessToken || null}
|
||||
isPublicPage={true}
|
||||
isDarkMode={false}
|
||||
toggleDarkMode={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react";
|
||||
import { ConfigProvider, theme as antdTheme } from "antd";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
|
||||
const DARK_MODE_STORAGE_KEY = "litellm-dark-mode";
|
||||
|
||||
interface ThemeContextType {
|
||||
logoUrl: string | null;
|
||||
setLogoUrl: (url: string | null) => void;
|
||||
faviconUrl: string | null;
|
||||
setFaviconUrl: (url: string | null) => void;
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
setIsDarkMode: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
|
@ -23,9 +29,51 @@ interface ThemeProviderProps {
|
|||
accessToken?: string | null;
|
||||
}
|
||||
|
||||
const readInitialDarkMode = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DARK_MODE_STORAGE_KEY);
|
||||
if (stored !== null) return stored === "true";
|
||||
} catch {
|
||||
// ignore localStorage access errors (e.g. privacy mode)
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessToken }) => {
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
|
||||
const [isDarkMode, setIsDarkModeState] = useState<boolean>(false);
|
||||
|
||||
// Hydrate dark mode from localStorage after mount to avoid SSR mismatch.
|
||||
useEffect(() => {
|
||||
setIsDarkModeState(readInitialDarkMode());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
if (isDarkMode) {
|
||||
root.classList.add("dark");
|
||||
root.style.colorScheme = "dark";
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
root.style.colorScheme = "light";
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
|
||||
} catch {
|
||||
// ignore localStorage write errors
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
const setIsDarkMode = useCallback((value: boolean) => {
|
||||
setIsDarkModeState(value);
|
||||
}, []);
|
||||
|
||||
const toggleDarkMode = useCallback(() => {
|
||||
setIsDarkModeState((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadThemeSettings = async () => {
|
||||
|
|
@ -71,8 +119,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessTo
|
|||
}, [faviconUrl]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ logoUrl, setLogoUrl, faviconUrl, setFaviconUrl }}>
|
||||
{children}
|
||||
<ThemeContext.Provider
|
||||
value={{ logoUrl, setLogoUrl, faviconUrl, setFaviconUrl, isDarkMode, toggleDarkMode, setIsDarkMode }}
|
||||
>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDarkMode ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue