diff --git a/apps/web/package.json b/apps/web/package.json index 7c3dd53..7660f00 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,7 +32,6 @@ "dotenv": "catalog:", "drizzle-orm": "^0.45.1", "lucide-react": "^0.525.0", - "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", "shadcn": "^3.6.2", diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 3ae8fd8..4531c1c 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,8 +1,12 @@ import { Link } from "@tanstack/react-router"; +import { Moon, Sun } from "lucide-react"; import UserMenu from "./user-menu"; +import { useTheme } from "@/lib/theme"; export default function Header() { + const { theme, toggleTheme } = useTheme(); + const links = [ { to: "/", label: "Home" }, { to: "/dashboard", label: "Dashboard" }, @@ -22,6 +26,20 @@ export default function Header() { })}
+
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index d3b6615..4ed19de 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -1,9 +1,12 @@ import { Toaster as SonnerToaster } from "sonner"; +import { useTheme } from "@/lib/theme"; + type ToasterProps = React.ComponentProps; function Toaster(props: ToasterProps) { - return ; + const { theme } = useTheme(); + return ; } export { Toaster }; diff --git a/apps/web/src/lib/flutter-bridge.ts b/apps/web/src/lib/flutter-bridge.ts index a8e7711..2c714ae 100644 --- a/apps/web/src/lib/flutter-bridge.ts +++ b/apps/web/src/lib/flutter-bridge.ts @@ -37,6 +37,7 @@ export type FlutterTimelineState = { items: Record; groupOrder: Array; selectedItemId: string | null; + darkMode: boolean; }; // --------------------------------------------------------------------------- diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx new file mode 100644 index 0000000..d7f6bc8 --- /dev/null +++ b/apps/web/src/lib/theme.tsx @@ -0,0 +1,73 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +type Theme = "light" | "dark"; + +type ThemeContextValue = { + theme: Theme; + toggleTheme: () => void; +}; + +const ThemeContext = createContext(null); + +const STORAGE_KEY = "zendegi-theme"; + +function getSystemPreference(): Theme { + if (typeof window === "undefined") return "dark"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function getInitialTheme(): Theme { + if (typeof window === "undefined") return "dark"; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + return getSystemPreference(); +} + +function applyTheme(theme: Theme) { + if (typeof document === "undefined") return; + document.documentElement.classList.toggle("dark", theme === "dark"); +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + // Listen for OS-level preference changes when no explicit preference is stored. + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (e: MediaQueryListEvent) => { + if (!localStorage.getItem(STORAGE_KEY)) { + setTheme(e.matches ? "dark" : "light"); + } + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + const toggleTheme = useCallback(() => { + setTheme((prev) => { + const next = prev === "dark" ? "light" : "dark"; + localStorage.setItem(STORAGE_KEY, next); + return next; + }); + }, []); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within a ThemeProvider"); + return ctx; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 572e857..b3d82a7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,11 +10,16 @@ import Header from "../components/header"; import appCss from "../index.css?url"; import type { QueryClient } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "@/lib/theme"; export interface RouterAppContext { queryClient: QueryClient; } +// Inline script that runs before paint to avoid a flash of wrong theme. +// Reads localStorage; falls back to system preference; falls back to dark. +const themeScript = `(function(){try{var t=localStorage.getItem("zendegi-theme");if(!t){t=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"}if(t==="dark"){document.documentElement.classList.add("dark")}else{document.documentElement.classList.remove("dark")}}catch(e){}})();`; + export const Route = createRootRouteWithContext()({ head: () => ({ meta: [ @@ -42,16 +47,19 @@ export const Route = createRootRouteWithContext()({ function RootDocument() { return ( - + +