diff --git a/app/layout.tsx b/app/layout.tsx index a3d202e..833f762 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,64 +1,16 @@ "use client"; -import { useState, useEffect, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import Link from "next/link"; import { ToastProvider } from "@/components/ToastProvider"; import { ConfirmDialogProvider } from "@/components/ConfirmDialogProvider"; import { SettingsProvider } from "@/components/SettingsProvider"; +import { ThemeProvider, useThemeMode } from "@/components/ThemeProvider"; import "./globals.css"; export default function RootLayout({ children }: { children: ReactNode }) { - const [isDark, setIsDark] = useState(false); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - const savedTheme = localStorage.getItem("theme"); - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const shouldBeDark = savedTheme ? savedTheme === "dark" : prefersDark; - - setIsDark(shouldBeDark); - if (shouldBeDark) { - document.documentElement.classList.add("dark"); - } - }, []); - - const toggleDarkMode = () => { - const newIsDark = !isDark; - setIsDark(newIsDark); - localStorage.setItem("theme", newIsDark ? "dark" : "light"); - - if (newIsDark) { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); - } - }; - - if (!mounted) { - return ( - - - Assertify - - - - - - -
-
Loading...
-
- - - ); - } - return ( - + Assertify @@ -73,29 +25,10 @@ export default function RootLayout({ children }: { children: ReactNode }) { -
- - - - -
- - {children} + + + {children} +
@@ -103,3 +36,72 @@ export default function RootLayout({ children }: { children: ReactNode }) { ); } + +function MobileDock() { + const { themeMode, toggleTheme, mounted } = useThemeMode(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleEsc = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEsc); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEsc); + }; + }, []); + + if (!mounted) return null; + + return ( +
+
+ + {open && ( +
+ setOpen(false)} + aria-label="Open settings" + title="Settings" + > + + + +
+ )} +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index fd14183..b7c0f2f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import SavedTestsList from "@/components/SavedTestsList"; import { useToast } from "@/components/ToastProvider"; @@ -8,6 +9,7 @@ import { useConfirmDialog } from "@/components/ConfirmDialogProvider"; import { buildAutoContext } from "@/lib/autoContext"; import { useSettings } from "@/components/SettingsProvider"; import { filterTestCasesBySettings } from "@/lib/testCaseUtils"; +import { useThemeMode } from "@/components/ThemeProvider"; export default function Home() { const [input, setInput] = useState(""); @@ -18,10 +20,23 @@ export default function Home() { const [needsManualContext, setNeedsManualContext] = useState(false); const [requirements, setRequirements] = useState(""); const [showRequirementsInput, setShowRequirementsInput] = useState(false); + const [showDesktopMenu, setShowDesktopMenu] = useState(false); + const controlsRef = useRef(null); const router = useRouter(); const { addToast } = useToast(); const { confirm } = useConfirmDialog(); const { settings } = useSettings(); + const { themeMode, themeLabel, toggleTheme, mounted } = useThemeMode(); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) { + setShowDesktopMenu(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); useEffect(() => { const savedKey = localStorage.getItem("openai_api_key"); @@ -197,18 +212,61 @@ export default function Home() { }; return ( -
-
-
+
+
+
{/* Header */} -
-

+
+

Assertify

AI-powered test case generation with boilerplate code

+ {mounted && ( +
+ + {showDesktopMenu && ( +
+ + + + +
+ )} +
+ )} {/* API Key Section */}
@@ -366,20 +424,20 @@ export default function Home() { {/* Features */} -
-
+
+

5 Test Types

-
+

8 Frameworks

-
+
diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx new file mode 100644 index 0000000..199e188 --- /dev/null +++ b/components/ThemeProvider.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +type ThemeMode = "light" | "dark" | "system"; + +interface ThemeContextValue { + themeMode: ThemeMode; + isDark: boolean; + themeLabel: string; + mounted: boolean; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [isDark, setIsDark] = useState(false); + const [themeMode, setThemeMode] = useState("system"); + const [mounted, setMounted] = useState(false); + + const applyTheme = useCallback((mode: ThemeMode, prefersDark: boolean) => { + const shouldUseDark = mode === "dark" || (mode === "system" && prefersDark); + setIsDark(shouldUseDark); + document.documentElement.classList.toggle("dark", shouldUseDark); + }, []); + + useEffect(() => { + setMounted(true); + const savedTheme = (localStorage.getItem("themeMode") || + localStorage.getItem("theme")) as ThemeMode | null; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + const resolvedMode: ThemeMode = + savedTheme === "light" || savedTheme === "dark" || savedTheme === "system" + ? savedTheme + : "system"; + + setThemeMode(resolvedMode); + applyTheme(resolvedMode, prefersDark); + }, [applyTheme]); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (event: MediaQueryListEvent) => { + if (themeMode === "system") { + applyTheme("system", event.matches); + } + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [applyTheme, themeMode]); + + const toggleTheme = useCallback(() => { + const nextMode: ThemeMode = + themeMode === "light" ? "dark" : themeMode === "dark" ? "system" : "light"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + setThemeMode(nextMode); + localStorage.setItem("themeMode", nextMode); + localStorage.setItem("theme", nextMode); + applyTheme(nextMode, prefersDark); + }, [applyTheme, themeMode]); + + const themeLabel = useMemo( + () => (themeMode === "system" ? "System" : themeMode === "dark" ? "Dark" : "Light"), + [themeMode] + ); + + const value = useMemo( + () => ({ themeMode, isDark, themeLabel, mounted, toggleTheme }), + [isDark, mounted, themeLabel, themeMode, toggleTheme] + ); + + return {children}; +} + +export function useThemeMode() { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useThemeMode must be used within a ThemeProvider"); + } + return ctx; +}