Attempt 2 to add a darkmode

This commit is contained in:
2025-10-17 12:38:22 +01:00
parent 59d16ebde1
commit 8e687fe176

View File

@@ -1,84 +1,66 @@
"use client"; "use client";
import { useEffect, useMemo, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import * as Icon from "react-bootstrap-icons"; import * as Icon from "react-bootstrap-icons";
import { Nav, Dropdown } from "react-bootstrap"; import { Nav, Dropdown } from "react-bootstrap";
type Theme = "light" | "dark" | "auto"; type Theme = "light" | "dark" | "auto";
const COOKIE = "theme"; const COOKIE = "NBN-theme";
function getCookie(name: string): string | null { const getCookie = (name: string) => {
if (typeof document === "undefined") return null;
const m = document.cookie.match(new RegExp("(^|; )" + name + "=([^;]+)")); const m = document.cookie.match(new RegExp("(^|; )" + name + "=([^;]+)"));
return m ? decodeURIComponent(m[2]) : null; return m ? decodeURIComponent(m[2]) : null;
} };
function setCookie(name: string, value: string) {
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax`; const setCookie = (name: string, value: string) => {
} document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax; Domain=specnext.dev`;
};
// Use a single function to read current system preference (works on iOS)
const prefersDark = () => const prefersDark = () =>
typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
export default function ThemeDropdown() { export default function ThemeDropdown() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [theme, setThemeState] = useState<Theme>("auto"); const [theme, setTheme] = useState<Theme>("auto");
const applyTheme = useCallback((t: Theme) => { const apply = useCallback((t: Theme) => {
const effective = t === "auto" ? (prefersDark() ? "dark" : "light") : t; const effective = t === "auto" ? (prefersDark() ? "dark" : "light") : t;
document.documentElement.setAttribute("data-bs-theme", effective); document.documentElement.setAttribute("data-bs-theme", effective);
}, []); }, []);
// Initial mount: read cookie and APPLY immediately (important for iOS)
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
const v = getCookie(COOKIE); const v = getCookie(COOKIE);
console.log("Cookie:", v);
const initial: Theme = v === "light" || v === "dark" || v === "auto" ? (v as Theme) : "auto"; const initial: Theme = v === "light" || v === "dark" || v === "auto" ? (v as Theme) : "auto";
setThemeState(initial); setTheme(initial);
applyTheme(initial); // ensure render matches auto right away // Important: apply immediately so “auto” reflects system after hydration
}, [applyTheme]); apply(initial);
}, [apply]);
// Follow system changes while in auto; include iOS visibility/page-show events
useEffect(() => { useEffect(() => {
if (!mounted || theme !== "auto") return; if (!mounted || theme !== "auto") return;
const mql = window.matchMedia("(prefers-color-scheme: dark)"); const mql = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyTheme("auto"); const onChange = () => apply("auto");
// Safari <14 uses addListener/removeListener mql.addEventListener("change", onChange);
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
} else if (typeof mql.addListener === "function") {
mql.addListener(onChange);
}
const onVisibility = () => applyTheme("auto"); const onPageShow = () => apply("auto");
const onPageShow = () => applyTheme("auto");
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("pageshow", onPageShow); window.addEventListener("pageshow", onPageShow);
return () => { return () => {
if (typeof mql.removeEventListener === "function") { mql.removeEventListener("change", onChange);
mql.removeEventListener("change", onChange);
} else if (typeof mql.removeListener === "function") {
mql.removeListener(onChange);
}
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pageshow", onPageShow); window.removeEventListener("pageshow", onPageShow);
}; };
}, [mounted, theme, applyTheme]); }, [mounted, theme, apply]);
const handleSetTheme = (t: Theme) => { const choose = (t: Theme) => {
setCookie(COOKIE, t); setCookie(COOKIE, t);
setThemeState(t); setTheme(t);
applyTheme(t); apply(t);
}; };
const isActive = (t: Theme) => theme === t; const isActive = (t: Theme) => theme === t;
const ToggleIcon = !mounted const ToggleIcon = !mounted
? Icon.CircleHalf ? Icon.CircleHalf
: theme === "dark" : theme === "dark"
@@ -94,39 +76,18 @@ export default function ThemeDropdown() {
<ToggleIcon /> <ToggleIcon />
<span className="d-lg-none ms-2" id="bd-theme-text">Toggle theme</span> <span className="d-lg-none ms-2" id="bd-theme-text">Toggle theme</span>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu aria-labelledby="bd-theme-text"> <Dropdown.Menu aria-labelledby="bd-theme-text">
<Dropdown.Item <Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("light")} onClick={() => choose("light")}>
as="button"
className="d-flex align-items-center"
aria-pressed={isActive("light")}
active={isActive("light")}
onClick={() => handleSetTheme("light")}
>
<Icon.SunFill /> <Icon.SunFill />
<span className="ms-2">Light</span> <span className="ms-2">Light</span>
{isActive("light") && <Icon.Check2 className="ms-auto" aria-hidden="true" />} {isActive("light") && <Icon.Check2 className="ms-auto" aria-hidden="true" />}
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("dark")} onClick={() => choose("dark")}>
<Dropdown.Item
as="button"
className="d-flex align-items-center"
aria-pressed={isActive("dark")}
active={isActive("dark")}
onClick={() => handleSetTheme("dark")}
>
<Icon.MoonStarsFill /> <Icon.MoonStarsFill />
<span className="ms-2">Dark</span> <span className="ms-2">Dark</span>
{isActive("dark") && <Icon.Check2 className="ms-auto" aria-hidden="true" />} {isActive("dark") && <Icon.Check2 className="ms-auto" aria-hidden="true" />}
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item as="button" className="d-flex align-items-center" active={isActive("auto")} onClick={() => choose("auto")}>
<Dropdown.Item
as="button"
className="d-flex align-items-center"
aria-pressed={isActive("auto")}
active={isActive("auto")}
onClick={() => handleSetTheme("auto")}
>
<Icon.CircleHalf /> <Icon.CircleHalf />
<span className="ms-2">Auto</span> <span className="ms-2">Auto</span>
{isActive("auto") && <Icon.Check2 className="ms-auto" aria-hidden="true" />} {isActive("auto") && <Icon.Check2 className="ms-auto" aria-hidden="true" />}