Skip to content

Commit 524ebc4

Browse files
committed
refactor: update theme handling and improve layout structure
- Removed the `next-themes` dependency and replaced it with a custom theme provider for better control over theme preferences. - Enhanced the `CommonProviders` component to support initial theme settings and migration from local storage. - Updated the `RootLayout` to include a theme initialization script and improved suspense handling for child components. - Refactored the `ThemeSwitcher` to utilize the new theme provider, ensuring consistent theme management across the application.
1 parent 4528d1a commit 524ebc4

9 files changed

Lines changed: 225 additions & 12 deletions

File tree

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"meilisearch": "^0.51.0",
5959
"modern-screenshot": "^4.6.8",
6060
"next": "^16.2.1",
61-
"next-themes": "^0.4.6",
6261
"pg": "^8.14.1",
6362
"react": "^19",
6463
"react-advanced-cropper": "^0.20.1",

src/app/layout.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
33
import "../styles/app.css";
44

55
import CommonProviders from "@/components/providers/CommonProviders";
6+
import RootProviders from "@/components/providers/root-providers";
67
import LanguageHydrator from "@/components/providers/LanguageHydrator";
78
import SessionHydrator from "@/components/providers/SessionHydrator";
89
import { CookieConsentPopup } from "@/components/CookieConsentPopup";
910
import { fontKohinoorBanglaRegular } from "@/lib/fonts";
1011
import { Toaster } from "@/components/toast";
12+
import { THEME_INIT_SCRIPT } from "@/lib/theme-init-script";
1113
import Script from "next/script";
1214
import React, { PropsWithChildren, Suspense } from "react";
1315

@@ -41,6 +43,9 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
4143
return (
4244
<html lang="bn" suppressHydrationWarning>
4345
<body style={fontKohinoorBanglaRegular.style}>
46+
<Script id="theme-init" strategy="beforeInteractive">
47+
{THEME_INIT_SCRIPT}
48+
</Script>
4449
<Script
4550
src="https://www.googletagmanager.com/gtag/js?id=G-F3VRW4H09N"
4651
strategy="afterInteractive"
@@ -82,13 +87,25 @@ const RootLayout: React.FC<PropsWithChildren> = ({ children }) => {
8287
}}
8388
/>
8489
<AuthKitProvider>
85-
<CommonProviders>
86-
<Suspense><SessionHydrator /></Suspense>
87-
<Suspense><LanguageHydrator /></Suspense>
88-
{children}
89-
<Toaster />
90-
<CookieConsentPopup />
91-
</CommonProviders>
90+
<Suspense
91+
fallback={
92+
<CommonProviders>
93+
<Suspense><SessionHydrator /></Suspense>
94+
<Suspense><LanguageHydrator /></Suspense>
95+
{children}
96+
<Toaster />
97+
<CookieConsentPopup />
98+
</CommonProviders>
99+
}
100+
>
101+
<RootProviders>
102+
<Suspense><SessionHydrator /></Suspense>
103+
<Suspense><LanguageHydrator /></Suspense>
104+
{children}
105+
<Toaster />
106+
<CookieConsentPopup />
107+
</RootProviders>
108+
</Suspense>
92109
</AuthKitProvider>
93110
</body>
94111
</html>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use server";
2+
3+
import {
4+
THEME_COOKIE_NAME,
5+
THEME_COOKIE_MAX_AGE,
6+
type ThemePreference,
7+
} from "@/lib/theme-cookie";
8+
import { env } from "@/env";
9+
import { cookies } from "next/headers";
10+
import { z } from "zod/v4";
11+
12+
const themeSchema = z.enum(["light", "dark", "system"]);
13+
14+
export async function setThemePreference(
15+
pref: ThemePreference,
16+
): Promise<{ ok: true } | { ok: false }> {
17+
const parsed = themeSchema.safeParse(pref);
18+
if (!parsed.success) return { ok: false };
19+
try {
20+
const store = await cookies();
21+
store.set(THEME_COOKIE_NAME, parsed.data, {
22+
path: "/",
23+
maxAge: THEME_COOKIE_MAX_AGE,
24+
sameSite: "lax",
25+
secure: env.NODE_ENV === "production",
26+
});
27+
} catch {
28+
return { ok: false };
29+
}
30+
return { ok: true };
31+
}

src/components/Navbar/ThemeSwitcher.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useTranslation } from "@/i18n/use-translation";
44
import { LightbulbIcon, MonitorIcon, MoonIcon } from "lucide-react";
5-
import { useTheme } from "next-themes";
5+
import { useTheme } from "@/components/providers/theme-provider";
66
import React from "react";
77
import {
88
DropdownMenu,

src/components/providers/CommonProviders.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
"use client";
22

3+
import type { ThemePreference } from "@/lib/theme-cookie";
34
import { tanstackQueryClient } from "@/lib/tanstack-query.client";
45
import { QueryClientProvider } from "@tanstack/react-query";
56
import React, { PropsWithChildren, Suspense } from "react";
67

78
import { jotaiStore } from "@/store/store";
89
import { Provider as JotaiProvider } from "jotai";
9-
import { ThemeProvider } from "next-themes";
10+
import { ThemeProvider } from "./theme-provider";
1011
import { AppConfirmProvider } from "../app-confirm";
1112
import { AppAlertProvider } from "../app-alert";
1213
import { AppLoginPopupProvider } from "../app-login-popup";
1314

14-
const CommonProviders: React.FC<PropsWithChildren> = ({ children }) => {
15+
type Props = PropsWithChildren<{
16+
initialTheme?: ThemePreference;
17+
/** When true, one-time migration from legacy `localStorage.theme` via server action. */
18+
migrateThemeFromLocalStorage?: boolean;
19+
}>;
20+
21+
const CommonProviders: React.FC<Props> = ({
22+
children,
23+
initialTheme = "system",
24+
migrateThemeFromLocalStorage = false,
25+
}) => {
1526
return (
1627
<JotaiProvider store={jotaiStore}>
1728
<QueryClientProvider client={tanstackQueryClient}>
1829
<AppConfirmProvider>
1930
<AppAlertProvider>
2031
<Suspense>
2132
<AppLoginPopupProvider>
22-
<ThemeProvider attribute="data-theme">
33+
<ThemeProvider
34+
initialTheme={initialTheme}
35+
migrateThemeFromLocalStorage={migrateThemeFromLocalStorage}
36+
>
2337
{children}
2438
</ThemeProvider>
2539
</AppLoginPopupProvider>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { THEME_COOKIE_NAME, parseThemePreference } from "@/lib/theme-cookie";
2+
import { cookies } from "next/headers";
3+
import type { PropsWithChildren } from "react";
4+
import CommonProviders from "./CommonProviders";
5+
6+
/**
7+
* Reads theme cookie on the server and passes it into the client provider tree.
8+
* Must render inside `<Suspense>` when using Cache Components.
9+
*/
10+
export default async function RootProviders({ children }: PropsWithChildren) {
11+
const cookieStore = await cookies();
12+
const themeCookie = cookieStore.get(THEME_COOKIE_NAME);
13+
const initialTheme = parseThemePreference(themeCookie?.value);
14+
const themeCookieMissing = themeCookie === undefined;
15+
16+
return (
17+
<CommonProviders
18+
initialTheme={initialTheme}
19+
migrateThemeFromLocalStorage={themeCookieMissing}
20+
>
21+
{children}
22+
</CommonProviders>
23+
);
24+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"use client";
2+
3+
import { setThemePreference } from "@/backend/services/theme.actions";
4+
import * as React from "react";
5+
6+
import { type ThemePreference } from "@/lib/theme-cookie";
7+
8+
export type { ThemePreference };
9+
10+
function getSystemTheme(): "light" | "dark" {
11+
if (typeof window === "undefined") return "light";
12+
return window.matchMedia("(prefers-color-scheme: dark)").matches
13+
? "dark"
14+
: "light";
15+
}
16+
17+
export function resolveTheme(pref: ThemePreference): "light" | "dark" {
18+
if (pref === "system") return getSystemTheme();
19+
return pref;
20+
}
21+
22+
function applyToDocument(resolved: "light" | "dark") {
23+
document.documentElement.setAttribute("data-theme", resolved);
24+
document.documentElement.style.colorScheme = resolved;
25+
}
26+
27+
export type ThemeContextValue = {
28+
theme: ThemePreference;
29+
setTheme: (t: ThemePreference) => void;
30+
resolvedTheme: "light" | "dark";
31+
};
32+
33+
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
34+
35+
export function useTheme(): ThemeContextValue {
36+
const ctx = React.useContext(ThemeContext);
37+
if (!ctx) {
38+
throw new Error("useTheme must be used within ThemeProvider");
39+
}
40+
return ctx;
41+
}
42+
43+
type ThemeProviderProps = {
44+
children: React.ReactNode;
45+
initialTheme: ThemePreference;
46+
migrateThemeFromLocalStorage: boolean;
47+
};
48+
49+
export function ThemeProvider({
50+
children,
51+
initialTheme,
52+
migrateThemeFromLocalStorage,
53+
}: ThemeProviderProps) {
54+
const [theme, setThemeState] = React.useState<ThemePreference>(initialTheme);
55+
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(
56+
"light",
57+
);
58+
59+
React.useEffect(() => {
60+
setThemeState(initialTheme);
61+
const resolved = resolveTheme(initialTheme);
62+
setResolvedTheme(resolved);
63+
applyToDocument(resolved);
64+
}, [initialTheme]);
65+
66+
React.useEffect(() => {
67+
const resolved = resolveTheme(theme);
68+
setResolvedTheme(resolved);
69+
applyToDocument(resolved);
70+
}, [theme]);
71+
72+
React.useEffect(() => {
73+
if (theme !== "system") return;
74+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
75+
const onChange = () => {
76+
const resolved = resolveTheme("system");
77+
setResolvedTheme(resolved);
78+
applyToDocument(resolved);
79+
};
80+
mq.addEventListener("change", onChange);
81+
return () => mq.removeEventListener("change", onChange);
82+
}, [theme]);
83+
84+
React.useEffect(() => {
85+
if (!migrateThemeFromLocalStorage) return;
86+
try {
87+
const v = localStorage.getItem("theme");
88+
if (v !== "light" && v !== "dark" && v !== "system") return;
89+
void setThemePreference(v).then((r) => {
90+
if (r.ok) setThemeState(v);
91+
});
92+
} catch {
93+
/* private mode */
94+
}
95+
}, [migrateThemeFromLocalStorage]);
96+
97+
const setTheme = React.useCallback((t: ThemePreference) => {
98+
setThemeState(t);
99+
void setThemePreference(t);
100+
}, []);
101+
102+
const value = React.useMemo(
103+
() => ({ theme, setTheme, resolvedTheme }),
104+
[theme, setTheme, resolvedTheme],
105+
);
106+
107+
return (
108+
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
109+
);
110+
}

src/lib/theme-cookie.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const THEME_COOKIE_NAME = "theme";
2+
3+
/** 1 year — matches typical “remember preference” behavior. */
4+
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
5+
6+
export type ThemePreference = "light" | "dark" | "system";
7+
8+
export function parseThemePreference(
9+
raw: string | undefined | null,
10+
): ThemePreference {
11+
if (raw === "light" || raw === "dark" || raw === "system") return raw;
12+
return "system";
13+
}

src/lib/theme-init-script.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Runs before paint. Reads `theme` cookie, migrates legacy `localStorage.theme` once,
3+
* resolves `system` via `prefers-color-scheme`. Keep in sync with `theme-cookie.ts`.
4+
*/
5+
export const THEME_INIT_SCRIPT = `(function(){try{var n='theme';var y=31536000;function gc(){var m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1].replace(/\\+/g,' ')):'';}function hc(){return new RegExp('(?:^|; )'+n+'=').test(document.cookie);}if(!hc()){try{var ls=localStorage.getItem(n);if(ls==='dark'||ls==='light'||ls==='system'){document.cookie=n+'='+encodeURIComponent(ls)+';path=/;max-age='+y+';SameSite=Lax'+(location.protocol==='https:'?';Secure':'');}}catch(e){}}var t=gc();var pref=(t==='dark'||t==='light'||t==='system')?t:'system';var r=pref==='dark'?'dark':pref==='light'?'light':(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',r);document.documentElement.style.colorScheme=r;}catch(e){}})();`;

0 commit comments

Comments
 (0)