Skip to content

Commit 78406dc

Browse files
committed
Add Theme Toggle
1 parent d723b02 commit 78406dc

5 files changed

Lines changed: 112 additions & 1 deletion

File tree

src/components/layout/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BookOpen, ExternalLink, Github } from "lucide-react";
2+
import { ThemeToggle } from "@/components/layout/ThemeToggle";
23
import { Button } from "@/components/ui/button";
34

45
export function Header() {
@@ -43,6 +44,7 @@ export function Header() {
4344
<span className="hidden sm:inline">Source</span>
4445
</a>
4546
</Button>
47+
<ThemeToggle />
4648
</nav>
4749
</div>
4850
</header>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Moon, Sun } from "lucide-react";
2+
import { Button } from "@/components/ui/button";
3+
import { useTheme } from "@/hooks/useTheme";
4+
5+
export function ThemeToggle() {
6+
const { theme, toggleTheme } = useTheme();
7+
8+
const icon =
9+
theme === "light" ? (
10+
<Sun className="h-4 w-4" />
11+
) : (
12+
<Moon className="h-4 w-4" />
13+
);
14+
15+
const label = theme === "light" ? "Light theme" : "Dark theme";
16+
17+
return (
18+
<Button
19+
variant="ghost"
20+
size="sm"
21+
onClick={toggleTheme}
22+
aria-label={label}
23+
title={label}
24+
>
25+
{icon}
26+
</Button>
27+
);
28+
}

src/context/ThemeContext.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
createContext,
3+
type ReactNode,
4+
useCallback,
5+
useEffect,
6+
useState,
7+
} from "react";
8+
9+
export type Theme = "light" | "dark";
10+
11+
export interface ThemeContextValue {
12+
theme: Theme;
13+
setTheme: (theme: Theme) => void;
14+
toggleTheme: () => void;
15+
}
16+
17+
export const ThemeContext = createContext<ThemeContextValue | null>(null);
18+
19+
const STORAGE_KEY = "keywrit-hub-theme";
20+
21+
function getInitialTheme(): Theme {
22+
if (typeof window === "undefined") return "light";
23+
const stored = localStorage.getItem(STORAGE_KEY);
24+
if (stored === "light" || stored === "dark") {
25+
return stored;
26+
}
27+
// Auto-detect from system preference on first load
28+
return window.matchMedia("(prefers-color-scheme: dark)").matches
29+
? "dark"
30+
: "light";
31+
}
32+
33+
export function ThemeProvider({ children }: { children: ReactNode }) {
34+
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
35+
36+
// Apply theme class to document
37+
useEffect(() => {
38+
const root = document.documentElement;
39+
if (theme === "dark") {
40+
root.classList.add("dark");
41+
} else {
42+
root.classList.remove("dark");
43+
}
44+
}, [theme]);
45+
46+
const setTheme = useCallback((newTheme: Theme) => {
47+
setThemeState(newTheme);
48+
localStorage.setItem(STORAGE_KEY, newTheme);
49+
}, []);
50+
51+
const toggleTheme = useCallback(() => {
52+
setThemeState((current) => {
53+
const next = current === "light" ? "dark" : "light";
54+
localStorage.setItem(STORAGE_KEY, next);
55+
return next;
56+
});
57+
}, []);
58+
59+
const value: ThemeContextValue = {
60+
theme,
61+
setTheme,
62+
toggleTheme,
63+
};
64+
65+
return (
66+
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
67+
);
68+
}

src/hooks/useTheme.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useContext } from "react";
2+
import { ThemeContext, type ThemeContextValue } from "@/context/ThemeContext";
3+
4+
export function useTheme(): ThemeContextValue {
5+
const context = useContext(ThemeContext);
6+
if (!context) {
7+
throw new Error("useTheme must be used within a ThemeProvider");
8+
}
9+
return context;
10+
}

src/main.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { StrictMode } from "react";
22
import { createRoot } from "react-dom/client";
33
import "./index.css";
4+
import { ThemeProvider } from "@/context/ThemeContext";
45
import App from "./App.tsx";
56

67
createRoot(document.getElementById("root")!).render(
78
<StrictMode>
8-
<App />
9+
<ThemeProvider>
10+
<App />
11+
</ThemeProvider>
912
</StrictMode>,
1013
);

0 commit comments

Comments
 (0)