diff --git a/webui/src/App.tsx b/webui/src/App.tsx index a909ccb..44a155b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -7,6 +7,7 @@ import SystemInfoDialog from "./components/SystemInfoDialog"; import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { useInstances } from "@/contexts/InstancesContext"; import { useAuth } from "@/contexts/AuthContext"; +import { ThemeProvider } from "@/contexts/ThemeContext"; function App() { const { isAuthenticated, isLoading: authLoading } = useAuth(); @@ -42,44 +43,50 @@ function App() { // Show loading spinner while checking auth if (authLoading) { return ( -
-
-
-

Loading...

+ +
+
+
+

Loading...

+
-
+ ); } // Show login dialog if not authenticated if (!isAuthenticated) { return ( -
- -
+ +
+ +
+
); } // Show main app if authenticated return ( -
-
-
- -
+ +
+
+
+ +
- + - -
+ +
+ ); } diff --git a/webui/src/__tests__/App.test.tsx b/webui/src/__tests__/App.test.tsx index 1d22531..04c1e34 100644 --- a/webui/src/__tests__/App.test.tsx +++ b/webui/src/__tests__/App.test.tsx @@ -55,6 +55,21 @@ describe('App Component - Critical Business Logic Only', () => { vi.mocked(instancesApi.list).mockResolvedValue(mockInstances) window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123') global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))) + + // Mock window.matchMedia for dark mode functionality + 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(), + })), + }) }) afterEach(() => { diff --git a/webui/src/components/Header.tsx b/webui/src/components/Header.tsx index ed272ed..3c7e0e1 100644 --- a/webui/src/components/Header.tsx +++ b/webui/src/components/Header.tsx @@ -1,6 +1,7 @@ import { Button } from "@/components/ui/button"; -import { HelpCircle, LogOut } from "lucide-react"; +import { HelpCircle, LogOut, Moon, Sun } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; +import { useTheme } from "@/contexts/ThemeContext"; interface HeaderProps { onCreateInstance: () => void; @@ -9,6 +10,7 @@ interface HeaderProps { function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { const { logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); const handleLogout = () => { if (confirm("Are you sure you want to logout?")) { @@ -17,10 +19,10 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { }; return ( -
+
-

+

Llamactl Dashboard

@@ -29,6 +31,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { Create Instance + +
) @@ -28,7 +28,7 @@ function InstanceList({ editInstance }: InstanceListProps) { if (error) { return (
-
+

Error loading instances

{error}

@@ -39,15 +39,15 @@ function InstanceList({ editInstance }: InstanceListProps) { if (instances.length === 0) { return (
-

No instances found

-

Create your first instance to get started

+

No instances found

+

Create your first instance to get started

) } return (
-

+

Instances ({instances.length})

diff --git a/webui/src/contexts/ThemeContext.tsx b/webui/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..d05d49a --- /dev/null +++ b/webui/src/contexts/ThemeContext.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; + +type Theme = "light" | "dark"; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: ReactNode; +} + +export function ThemeProvider({ children }: ThemeProviderProps) { + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") { + return stored; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + }); + + useEffect(() => { + const root = document.documentElement; + + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + + localStorage.setItem("theme", theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prevTheme => prevTheme === "light" ? "dark" : "light"); + }; + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} \ No newline at end of file