feat(toolrate): light/dark/system theme toggle with FOUC guard
Build & Push Docker Image / build (push) Successful in 2m30s
Build & Push Docker Image / build (push) Successful in 2m30s
- use-theme hook: localStorage (toolrate-theme, default system), matchMedia change listener, shared singleton listener, sets .dark + color-scheme - ThemeToggle: lightbulb quick toggle (light/dark) + dropdown (light/dark/system) - Sidebar footer + mobile header + standalone login page - inline script in index.html to apply theme pre-render (no FOUC) - recharts axis ticks use hsl(var(--foreground)) for dark-mode readability
This commit is contained in:
@@ -16,6 +16,18 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem("toolrate-theme");
|
||||||
|
var dark = stored === "dark" ||
|
||||||
|
(stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.classList.toggle("dark", dark);
|
||||||
|
root.style.colorScheme = dark ? "dark" : "light";
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, Sh
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
@@ -43,7 +44,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2">
|
||||||
<Skeleton className="w-8 h-8 rounded-full" />
|
<Skeleton className="w-8 h-8 rounded-full" />
|
||||||
@@ -97,18 +101,21 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<Wrench className="w-5 h-5" />
|
<Wrench className="w-5 h-5" />
|
||||||
<span>ToolRate</span>
|
<span>ToolRate</span>
|
||||||
</div>
|
</div>
|
||||||
{!isLoading && (
|
<div className="flex items-center gap-1">
|
||||||
isAuthenticated ? (
|
<ThemeToggle />
|
||||||
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
|
{!isLoading && (
|
||||||
<LogOut className="w-4 h-4" />
|
isAuthenticated ? (
|
||||||
</Button>
|
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
|
||||||
) : (
|
<LogOut className="w-4 h-4" />
|
||||||
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
|
</Button>
|
||||||
<LogIn className="w-4 h-4 mr-1" />
|
) : (
|
||||||
Sign in
|
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
|
||||||
</Button>
|
<LogIn className="w-4 h-4 mr-1" />
|
||||||
)
|
Sign in
|
||||||
)}
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 p-6 md:p-8 overflow-auto">
|
<div className="flex-1 p-6 md:p-8 overflow-auto">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useTheme, type Theme } from "@/hooks/use-theme";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
|
function quickToggle() {
|
||||||
|
setTheme(isDark ? "light" : "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5" data-testid="theme-toggle">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={quickToggle}
|
||||||
|
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
||||||
|
data-testid="button-theme-quick-toggle"
|
||||||
|
>
|
||||||
|
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
aria-label="Choose theme"
|
||||||
|
title="Choose theme"
|
||||||
|
data-testid="button-theme-menu"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" sideOffset={6}>
|
||||||
|
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||||
|
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
|
||||||
|
<DropdownMenuRadioItem value="light">
|
||||||
|
<Sun className="w-4 h-4 mr-2" />
|
||||||
|
Light
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark">
|
||||||
|
<Moon className="w-4 h-4 mr-2" />
|
||||||
|
Dark
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system">
|
||||||
|
<Monitor className="w-4 h-4 mr-2" />
|
||||||
|
System
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "toolrate-theme";
|
||||||
|
const THEME_VALUES = ["light", "dark", "system"] as const;
|
||||||
|
|
||||||
|
export type Theme = (typeof THEME_VALUES)[number];
|
||||||
|
type ResolvedTheme = "light" | "dark";
|
||||||
|
|
||||||
|
function darkQuery(): MediaQueryList {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(theme: Theme): ResolvedTheme {
|
||||||
|
if (theme === "system") {
|
||||||
|
return typeof window !== "undefined" && darkQuery().matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(resolved: ResolvedTheme) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.toggle("dark", resolved === "dark");
|
||||||
|
root.style.colorScheme = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredTheme(): Theme {
|
||||||
|
if (typeof window === "undefined") return "system";
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored && (THEME_VALUES as readonly string[]).includes(stored)
|
||||||
|
? (stored as Theme)
|
||||||
|
: "system";
|
||||||
|
} catch {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeTheme(theme: Theme) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = new Set<(theme: Theme) => void>();
|
||||||
|
|
||||||
|
function broadcast(theme: Theme) {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
|
||||||
|
|
||||||
|
// Keep every mounted hook instance in sync (sidebar, mobile header, login).
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (next: Theme) => setThemeState(next);
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(resolveTheme(theme));
|
||||||
|
if (theme !== "system") {
|
||||||
|
storeTheme(theme);
|
||||||
|
broadcast(theme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onChange = () => applyTheme(resolveTheme("system"));
|
||||||
|
darkQuery().addEventListener("change", onChange);
|
||||||
|
return () => darkQuery().removeEventListener("change", onChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
function setTheme(next: Theme) {
|
||||||
|
if (!(THEME_VALUES as readonly string[]).includes(next)) return;
|
||||||
|
setThemeState(next);
|
||||||
|
storeTheme(next);
|
||||||
|
broadcast(next);
|
||||||
|
applyTheme(resolveTheme(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
resolvedTheme: resolveTheme(theme),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -119,8 +119,8 @@ export default function Analytics() {
|
|||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={topToolsChartData} margin={{ top: 20, right: 30, left: 0, bottom: 5 }}>
|
<BarChart data={topToolsChartData} margin={{ top: 20, right: 30, left: 0, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} />
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||||
<YAxis domain={[0, 5]} tickLine={false} axisLine={false} tickFormatter={(v) => `${v}`} />
|
<YAxis domain={[0, 5]} tickLine={false} axisLine={false} tickFormatter={(v) => `${v}`} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
cursor={{ fill: 'hsl(var(--muted))' }}
|
cursor={{ fill: 'hsl(var(--muted))' }}
|
||||||
contentStyle={{ borderRadius: '8px', border: '1px solid hsl(var(--border))' }}
|
contentStyle={{ borderRadius: '8px', border: '1px solid hsl(var(--border))' }}
|
||||||
@@ -175,7 +175,7 @@ export default function Analytics() {
|
|||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||||
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||||
<YAxis hide />
|
<YAxis hide />
|
||||||
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
||||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
||||||
@@ -189,7 +189,7 @@ export default function Analytics() {
|
|||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
||||||
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fontSize: 12 }} />
|
<XAxis dataKey="score" tickLine={false} axisLine={false} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||||
<YAxis hide />
|
<YAxis hide />
|
||||||
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
<RechartsTooltip cursor={{ fill: 'hsl(var(--muted))' }} contentStyle={{ borderRadius: '6px', border: '1px solid hsl(var(--border))' }} />
|
||||||
<Bar dataKey="count" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
<Bar dataKey="count" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} maxBarSize={40} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { Wrench, AlertCircle } from "lucide-react";
|
import { Wrench, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -40,6 +41,9 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
<div className="w-full max-w-sm space-y-6">
|
<div className="w-full max-w-sm space-y-6">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<div className="flex items-center gap-2 text-primary font-bold text-2xl">
|
<div className="flex items-center gap-2 text-primary font-bold text-2xl">
|
||||||
|
|||||||
@@ -697,7 +697,7 @@ export default function ToolDetail() {
|
|||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={usefulnessData} layout="vertical" margin={{ top: 0, right: 0, bottom: 0, left: -20 }}>
|
<BarChart data={usefulnessData} layout="vertical" margin={{ top: 0, right: 0, bottom: 0, left: -20 }}>
|
||||||
<XAxis type="number" hide />
|
<XAxis type="number" hide />
|
||||||
<YAxis dataKey="score" type="category" axisLine={false} tickLine={false} tickFormatter={(val) => `${val} ★`} />
|
<YAxis dataKey="score" type="category" axisLine={false} tickLine={false} tickFormatter={(val) => `${val} ★`} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||||
<Tooltip cursor={{ fill: 'transparent' }} />
|
<Tooltip cursor={{ fill: 'transparent' }} />
|
||||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} barSize={12} />
|
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} barSize={12} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
|
|||||||
Reference in New Issue
Block a user