Skip to content

Commit f679190

Browse files
Added dark and light mode toggle
1 parent 322da3f commit f679190

5 files changed

Lines changed: 110 additions & 9 deletions

File tree

src/components/theme-provider.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { setThemeServerFn } from "~/server/theme";
2+
import { createContext, use } from "react";
3+
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
4+
import { themeQuery } from "~/lib/queries";
5+
6+
export type Theme = "light" | "dark";
7+
8+
type ThemeContextVal = { theme: Theme; setTheme: (val: Theme) => void };
9+
10+
const ThemeContext = createContext<ThemeContextVal | null>(null);
11+
12+
export function ThemeProvider({
13+
children,
14+
}: Readonly<{ children: React.ReactNode }>) {
15+
const { data: theme } = useSuspenseQuery(themeQuery);
16+
const queryClient = useQueryClient();
17+
18+
function setTheme(val: Theme) {
19+
queryClient.setQueryData(themeQuery.queryKey, val);
20+
setThemeServerFn({ data: val });
21+
}
22+
23+
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
24+
}
25+
26+
export function useTheme() {
27+
const val = use(ThemeContext);
28+
if (!val) throw new Error("useTheme called outside of ThemeProvider!");
29+
return val;
30+
}

src/components/theme-toggle.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useTheme } from "~/components/theme-provider";
2+
import { Button } from "./ui/button";
3+
import { Moon, Sun } from "lucide-react";
4+
5+
export function ThemeToggle() {
6+
const { theme, setTheme } = useTheme();
7+
8+
return (
9+
<Button
10+
variant="outline"
11+
size="icon"
12+
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
13+
>
14+
{theme === "light" ? <Sun /> : <Moon />}
15+
</Button>
16+
);
17+
}

src/lib/queries.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
import { queryOptions } from "@tanstack/react-query";
22
import { getStats } from "~/server/get-stats";
3+
import { getThemeServerFn } from "~/server/theme";
4+
5+
export const themeQuery = queryOptions({
6+
queryKey: ["theme"],
7+
queryFn: () => {
8+
const theme = getThemeServerFn();
9+
if (!theme) {
10+
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
11+
? "dark"
12+
: "light";
13+
return theme;
14+
}
15+
return theme;
16+
},
17+
});
318

419
export const statsQuery = queryOptions({
520
queryKey: ["stats"],

src/routes/__root.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
import { wrapCreateRootRouteWithSentry } from "@sentry/tanstackstart-react";
1111
import appCss from "~/styles/app.css?url";
1212
import { seo } from "~/lib/seo";
13+
import { ThemeProvider, useTheme } from "~/components/theme-provider";
14+
import { ThemeToggle } from "~/components/theme-toggle";
15+
import { themeQuery } from "~/lib/queries";
1316

1417
export const Route = wrapCreateRootRouteWithSentry(
1518
createRootRouteWithContext<{
@@ -40,31 +43,47 @@ export const Route = wrapCreateRootRouteWithSentry(
4043
},
4144
],
4245
}),
46+
loader: ({ context }) => {
47+
context.queryClient.ensureQueryData(themeQuery);
48+
},
4349
component: RootComponent,
4450
notFoundComponent: DefaultGlobalNotFound,
4551
});
4652

4753
function RootComponent() {
4854
return (
49-
<RootDocument>
50-
<Outlet />
51-
</RootDocument>
55+
<ThemeProvider>
56+
<RootDocument>
57+
<Outlet />
58+
</RootDocument>
59+
</ThemeProvider>
5260
);
5361
}
5462

5563
function RootDocument({ children }: Readonly<{ children: React.ReactNode }>) {
64+
const { theme } = useTheme();
65+
5666
return (
57-
<html>
67+
<html className={theme} suppressHydrationWarning>
5868
<head>
5969
<HeadContent />
60-
<script
61-
defer
62-
src={import.meta.env.VITE_ANALYTICS_SCRIPT}
63-
data-website-id={import.meta.env.VITE_ANALYTICS_WEBSITE_ID}
64-
></script>
70+
{import.meta.env.VITE_ANALYTICS_SCRIPT &&
71+
import.meta.env.VITE_ANALYTICS_WEBSITE_ID && (
72+
<script
73+
defer
74+
src={import.meta.env.VITE_ANALYTICS_SCRIPT}
75+
data-website-id={import.meta.env.VITE_ANALYTICS_WEBSITE_ID}
76+
></script>
77+
)}
6578
</head>
6679
<body>
6780
{children}
81+
82+
{/* Theme Toggle - Fixed position in top-right corner */}
83+
<div className="fixed top-4 right-4 z-50">
84+
<ThemeToggle />
85+
</div>
86+
6887
<Scripts />
6988
</body>
7089
</html>

src/server/theme.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { type Theme } from "~/components/theme-provider";
2+
import { createServerFn } from "@tanstack/react-start";
3+
import { getCookie, setCookie } from "@tanstack/react-start/server";
4+
5+
const storageKey = "ui-theme";
6+
7+
export const getThemeServerFn = createServerFn().handler(async () => {
8+
return (getCookie(storageKey) || "dark") as Theme;
9+
});
10+
11+
export const setThemeServerFn = createServerFn({ method: "POST" })
12+
.validator((data: unknown) => {
13+
if (typeof data != "string" || (data != "dark" && data != "light")) {
14+
throw new Error("Invalid theme provided");
15+
}
16+
return data as NonNullable<Theme>;
17+
})
18+
.handler(async ({ data }) => {
19+
setCookie(storageKey, data);
20+
});

0 commit comments

Comments
 (0)