Skip to content

Commit 1f03d97

Browse files
authored
Merge pull request #19 from ShirleyRex/0.x
Merge:0.x to Master
2 parents d631455 + 599108b commit 1f03d97

3 files changed

Lines changed: 235 additions & 84 deletions

File tree

app/layout.tsx

Lines changed: 76 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,16 @@
11
"use client";
22

3-
import { useState, useEffect, type ReactNode } from "react";
3+
import { useEffect, useRef, useState, type ReactNode } from "react";
44
import Link from "next/link";
55
import { ToastProvider } from "@/components/ToastProvider";
66
import { ConfirmDialogProvider } from "@/components/ConfirmDialogProvider";
77
import { SettingsProvider } from "@/components/SettingsProvider";
8+
import { ThemeProvider, useThemeMode } from "@/components/ThemeProvider";
89
import "./globals.css";
910

1011
export default function RootLayout({ children }: { children: ReactNode }) {
11-
const [isDark, setIsDark] = useState(false);
12-
const [mounted, setMounted] = useState(false);
13-
14-
useEffect(() => {
15-
setMounted(true);
16-
const savedTheme = localStorage.getItem("theme");
17-
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
18-
const shouldBeDark = savedTheme ? savedTheme === "dark" : prefersDark;
19-
20-
setIsDark(shouldBeDark);
21-
if (shouldBeDark) {
22-
document.documentElement.classList.add("dark");
23-
}
24-
}, []);
25-
26-
const toggleDarkMode = () => {
27-
const newIsDark = !isDark;
28-
setIsDark(newIsDark);
29-
localStorage.setItem("theme", newIsDark ? "dark" : "light");
30-
31-
if (newIsDark) {
32-
document.documentElement.classList.add("dark");
33-
} else {
34-
document.documentElement.classList.remove("dark");
35-
}
36-
};
37-
38-
if (!mounted) {
39-
return (
40-
<html lang="en">
41-
<head>
42-
<title>Assertify</title>
43-
<meta name="description" content="Assertify - AI-powered test case generator" />
44-
<meta name="viewport" content="width=device-width, initial-scale=1" />
45-
<link
46-
rel="stylesheet"
47-
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
48-
/>
49-
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
50-
</head>
51-
<body>
52-
<div className="min-h-screen flex items-center justify-center">
53-
<div className="text-slate-600">Loading...</div>
54-
</div>
55-
</body>
56-
</html>
57-
);
58-
}
59-
6012
return (
61-
<html lang="en" className={isDark ? "dark" : ""}>
13+
<html lang="en">
6214
<head>
6315
<title>Assertify</title>
6416
<meta name="description" content="Assertify - AI-powered test case generator" />
@@ -73,33 +25,83 @@ export default function RootLayout({ children }: { children: ReactNode }) {
7325
<ConfirmDialogProvider>
7426
<SettingsProvider>
7527
<ToastProvider>
76-
<div className="fixed top-4 right-4 z-50 flex flex-col gap-3">
77-
<Link
78-
href="/settings"
79-
className="flex h-12 w-12 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-lg transition-colors hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
80-
aria-label="Open settings"
81-
>
82-
<i className="fas fa-gear"></i>
83-
</Link>
84-
<button
85-
onClick={toggleDarkMode}
86-
className="flex h-12 w-12 items-center justify-center rounded-full border border-slate-200 bg-slate-200 text-slate-900 shadow-lg transition-colors hover:bg-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-yellow-400 dark:hover:bg-slate-600"
87-
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
88-
aria-label="Toggle dark mode"
89-
>
90-
{isDark ? (
91-
<i className="fas fa-sun text-lg"></i>
92-
) : (
93-
<i className="fas fa-moon text-lg"></i>
94-
)}
95-
</button>
96-
</div>
97-
98-
{children}
28+
<ThemeProvider>
29+
<MobileDock />
30+
{children}
31+
</ThemeProvider>
9932
</ToastProvider>
10033
</SettingsProvider>
10134
</ConfirmDialogProvider>
10235
</body>
10336
</html>
10437
);
10538
}
39+
40+
function MobileDock() {
41+
const { themeMode, toggleTheme, mounted } = useThemeMode();
42+
const [open, setOpen] = useState(false);
43+
const menuRef = useRef<HTMLDivElement | null>(null);
44+
45+
useEffect(() => {
46+
const handleClickOutside = (event: MouseEvent) => {
47+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
48+
setOpen(false);
49+
}
50+
};
51+
const handleEsc = (event: KeyboardEvent) => {
52+
if (event.key === "Escape") setOpen(false);
53+
};
54+
document.addEventListener("mousedown", handleClickOutside);
55+
document.addEventListener("keydown", handleEsc);
56+
return () => {
57+
document.removeEventListener("mousedown", handleClickOutside);
58+
document.removeEventListener("keydown", handleEsc);
59+
};
60+
}, []);
61+
62+
if (!mounted) return null;
63+
64+
return (
65+
<div ref={menuRef} className="fixed right-4 top-4 z-50 sm:hidden">
66+
<div className="relative">
67+
<button
68+
onClick={() => setOpen((prev) => !prev)}
69+
className="flex h-12 w-12 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-lg transition-colors hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
70+
aria-label="Open controls"
71+
title="Controls"
72+
>
73+
<i className="fas fa-gear"></i>
74+
</button>
75+
{open && (
76+
<div className="absolute right-0 top-14 flex items-center gap-2 rounded-full border border-slate-200 bg-white/95 px-2 py-2 text-sm shadow-xl backdrop-blur dark:border-slate-700 dark:bg-slate-800/95">
77+
<Link
78+
href="/settings"
79+
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700"
80+
onClick={() => setOpen(false)}
81+
aria-label="Open settings"
82+
title="Settings"
83+
>
84+
<i className="fas fa-gear" aria-hidden="true"></i>
85+
</Link>
86+
<button
87+
onClick={() => {
88+
toggleTheme();
89+
}}
90+
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700"
91+
title={`Switch theme (current: ${themeMode})`}
92+
aria-label="Toggle theme mode"
93+
>
94+
{themeMode === "system" ? (
95+
<i className="fas fa-circle-half-stroke" aria-hidden="true"></i>
96+
) : themeMode === "dark" ? (
97+
<i className="fas fa-moon" aria-hidden="true"></i>
98+
) : (
99+
<i className="fas fa-sun" aria-hidden="true"></i>
100+
)}
101+
</button>
102+
</div>
103+
)}
104+
</div>
105+
</div>
106+
);
107+
}

app/page.tsx

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState, useEffect, useRef } from "react";
4+
import Link from "next/link";
45
import { useRouter } from "next/navigation";
56
import SavedTestsList from "@/components/SavedTestsList";
67
import { useToast } from "@/components/ToastProvider";
78
import { useConfirmDialog } from "@/components/ConfirmDialogProvider";
89
import { buildAutoContext } from "@/lib/autoContext";
910
import { useSettings } from "@/components/SettingsProvider";
1011
import { filterTestCasesBySettings } from "@/lib/testCaseUtils";
12+
import { useThemeMode } from "@/components/ThemeProvider";
1113

1214
export default function Home() {
1315
const [input, setInput] = useState("");
@@ -18,10 +20,23 @@ export default function Home() {
1820
const [needsManualContext, setNeedsManualContext] = useState(false);
1921
const [requirements, setRequirements] = useState("");
2022
const [showRequirementsInput, setShowRequirementsInput] = useState(false);
23+
const [showDesktopMenu, setShowDesktopMenu] = useState(false);
24+
const controlsRef = useRef<HTMLDivElement | null>(null);
2125
const router = useRouter();
2226
const { addToast } = useToast();
2327
const { confirm } = useConfirmDialog();
2428
const { settings } = useSettings();
29+
const { themeMode, themeLabel, toggleTheme, mounted } = useThemeMode();
30+
31+
useEffect(() => {
32+
const handleClickOutside = (event: MouseEvent) => {
33+
if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) {
34+
setShowDesktopMenu(false);
35+
}
36+
};
37+
document.addEventListener("mousedown", handleClickOutside);
38+
return () => document.removeEventListener("mousedown", handleClickOutside);
39+
}, []);
2540

2641
useEffect(() => {
2742
const savedKey = localStorage.getItem("openai_api_key");
@@ -197,18 +212,61 @@ export default function Home() {
197212
};
198213

199214
return (
200-
<div className="min-h-screen flex items-center justify-center px-4 bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-slate-900 dark:to-slate-800">
201-
<div className="max-w-2xl w-full">
202-
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl p-8 md:p-12">
215+
<div className="relative min-h-[100dvh] bg-gradient-to-br from-blue-50 to-indigo-100 px-0 dark:from-slate-900 dark:to-slate-800">
216+
<div className="mx-auto flex min-h-[100dvh] w-full max-w-3xl items-stretch px-0 pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] sm:px-6 sm:pb-12 sm:pt-12">
217+
<div className="relative flex w-full flex-1 flex-col rounded-none bg-white px-5 py-7 shadow-none dark:bg-slate-800 sm:rounded-2xl sm:px-8 sm:py-10 sm:shadow-2xl md:px-12">
203218
{/* Header */}
204-
<div className="text-center mb-8">
205-
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400 bg-clip-text text-transparent mb-4">
219+
<div className="text-center mb-6">
220+
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400 bg-clip-text text-transparent mb-3">
206221
Assertify
207222
</h1>
208223
<p className="text-lg text-slate-600 dark:text-slate-300">
209224
AI-powered test case generation with boilerplate code
210225
</p>
211226
</div>
227+
{mounted && (
228+
<div
229+
ref={controlsRef}
230+
className="absolute right-4 top-4 hidden sm:flex flex-col items-end gap-2"
231+
>
232+
<button
233+
onClick={() => setShowDesktopMenu((prev) => !prev)}
234+
className="flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/90 text-slate-700 shadow-sm backdrop-blur transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-200 dark:hover:bg-slate-700"
235+
aria-label="Open controls"
236+
title="Settings"
237+
>
238+
<i className="fas fa-gear text-base" aria-hidden="true"></i>
239+
</button>
240+
{showDesktopMenu && (
241+
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-white/95 px-2 py-2 text-sm shadow-xl backdrop-blur dark:border-slate-700 dark:bg-slate-800/95">
242+
<Link
243+
href="/settings"
244+
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700"
245+
aria-label="Open settings"
246+
title="Settings"
247+
>
248+
<i className="fas fa-gear" aria-hidden="true"></i>
249+
</Link>
250+
<button
251+
onClick={() => {
252+
toggleTheme();
253+
}}
254+
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700"
255+
title={`Switch theme (current: ${themeLabel})`}
256+
aria-label="Toggle theme mode"
257+
>
258+
{themeMode === "system" ? (
259+
<i className="fas fa-circle-half-stroke" aria-hidden="true"></i>
260+
) : themeMode === "dark" ? (
261+
<i className="fas fa-moon" aria-hidden="true"></i>
262+
) : (
263+
<i className="fas fa-sun" aria-hidden="true"></i>
264+
)}
265+
</button>
266+
</div>
267+
)}
268+
</div>
269+
)}
212270

213271
{/* API Key Section */}
214272
<div className="mb-8 p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border-l-4 border-blue-600">
@@ -366,20 +424,20 @@ export default function Home() {
366424
</button>
367425

368426
{/* Features */}
369-
<div className="grid grid-cols-3 gap-4 mt-12 pt-8 border-t border-slate-200 dark:border-slate-700">
370-
<div className="text-center">
427+
<div className="grid grid-cols-1 gap-4 mt-12 pt-8 border-t border-slate-200 dark:border-slate-700 sm:grid-cols-3">
428+
<div className="text-center rounded-xl bg-slate-50/60 p-4 dark:bg-slate-900/30 sm:bg-transparent sm:p-0">
371429
<div className="text-3xl mb-2 text-blue-600 dark:text-blue-400">
372430
<i className="fas fa-flask"></i>
373431
</div>
374432
<p className="text-sm text-slate-600 dark:text-slate-400">5 Test Types</p>
375433
</div>
376-
<div className="text-center">
434+
<div className="text-center rounded-xl bg-slate-50/60 p-4 dark:bg-slate-900/30 sm:bg-transparent sm:p-0">
377435
<div className="text-3xl mb-2 text-green-600 dark:text-green-400">
378436
<i className="fas fa-code"></i>
379437
</div>
380438
<p className="text-sm text-slate-600 dark:text-slate-400">8 Frameworks</p>
381439
</div>
382-
<div className="text-center">
440+
<div className="text-center rounded-xl bg-slate-50/60 p-4 dark:bg-slate-900/30 sm:bg-transparent sm:p-0">
383441
<div className="text-3xl mb-2 text-yellow-600 dark:text-yellow-400">
384442
<i className="fas fa-bolt"></i>
385443
</div>

components/ThemeProvider.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"use client";
2+
3+
import {
4+
createContext,
5+
useCallback,
6+
useContext,
7+
useEffect,
8+
useMemo,
9+
useState,
10+
type ReactNode,
11+
} from "react";
12+
13+
type ThemeMode = "light" | "dark" | "system";
14+
15+
interface ThemeContextValue {
16+
themeMode: ThemeMode;
17+
isDark: boolean;
18+
themeLabel: string;
19+
mounted: boolean;
20+
toggleTheme: () => void;
21+
}
22+
23+
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
24+
25+
export function ThemeProvider({ children }: { children: ReactNode }) {
26+
const [isDark, setIsDark] = useState(false);
27+
const [themeMode, setThemeMode] = useState<ThemeMode>("system");
28+
const [mounted, setMounted] = useState(false);
29+
30+
const applyTheme = useCallback((mode: ThemeMode, prefersDark: boolean) => {
31+
const shouldUseDark = mode === "dark" || (mode === "system" && prefersDark);
32+
setIsDark(shouldUseDark);
33+
document.documentElement.classList.toggle("dark", shouldUseDark);
34+
}, []);
35+
36+
useEffect(() => {
37+
setMounted(true);
38+
const savedTheme = (localStorage.getItem("themeMode") ||
39+
localStorage.getItem("theme")) as ThemeMode | null;
40+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
41+
const resolvedMode: ThemeMode =
42+
savedTheme === "light" || savedTheme === "dark" || savedTheme === "system"
43+
? savedTheme
44+
: "system";
45+
46+
setThemeMode(resolvedMode);
47+
applyTheme(resolvedMode, prefersDark);
48+
}, [applyTheme]);
49+
50+
useEffect(() => {
51+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
52+
const handleChange = (event: MediaQueryListEvent) => {
53+
if (themeMode === "system") {
54+
applyTheme("system", event.matches);
55+
}
56+
};
57+
58+
mediaQuery.addEventListener("change", handleChange);
59+
return () => mediaQuery.removeEventListener("change", handleChange);
60+
}, [applyTheme, themeMode]);
61+
62+
const toggleTheme = useCallback(() => {
63+
const nextMode: ThemeMode =
64+
themeMode === "light" ? "dark" : themeMode === "dark" ? "system" : "light";
65+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
66+
setThemeMode(nextMode);
67+
localStorage.setItem("themeMode", nextMode);
68+
localStorage.setItem("theme", nextMode);
69+
applyTheme(nextMode, prefersDark);
70+
}, [applyTheme, themeMode]);
71+
72+
const themeLabel = useMemo(
73+
() => (themeMode === "system" ? "System" : themeMode === "dark" ? "Dark" : "Light"),
74+
[themeMode]
75+
);
76+
77+
const value = useMemo(
78+
() => ({ themeMode, isDark, themeLabel, mounted, toggleTheme }),
79+
[isDark, mounted, themeLabel, themeMode, toggleTheme]
80+
);
81+
82+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
83+
}
84+
85+
export function useThemeMode() {
86+
const ctx = useContext(ThemeContext);
87+
if (!ctx) {
88+
throw new Error("useThemeMode must be used within a ThemeProvider");
89+
}
90+
return ctx;
91+
}

0 commit comments

Comments
 (0)