add dark theme toggle

This commit is contained in:
2026-03-02 13:00:30 +01:00
parent 9d5ea49d85
commit de0be12aab
10 changed files with 125 additions and 24 deletions

View File

@@ -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",

View File

@@ -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() {
})}
</nav>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleTheme}
className="text-muted-foreground hover:text-foreground inline-flex size-8 items-center justify-center rounded-md transition-colors"
aria-label={
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
}
>
{theme === "dark" ? (
<Sun className="size-4" />
) : (
<Moon className="size-4" />
)}
</button>
<UserMenu />
</div>
</div>

View File

@@ -1,9 +1,12 @@
import { Toaster as SonnerToaster } from "sonner";
import { useTheme } from "@/lib/theme";
type ToasterProps = React.ComponentProps<typeof SonnerToaster>;
function Toaster(props: ToasterProps) {
return <SonnerToaster theme="dark" {...props} />;
const { theme } = useTheme();
return <SonnerToaster theme={theme} {...props} />;
}
export { Toaster };

View File

@@ -37,6 +37,7 @@ export type FlutterTimelineState = {
items: Record<string, FlutterTimelineItem>;
groupOrder: Array<string>;
selectedItemId: string | null;
darkMode: boolean;
};
// ---------------------------------------------------------------------------

View File

@@ -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<ThemeContextValue | null>(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<Theme>(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 <ThemeContext value={{ theme, toggleTheme }}>{children}</ThemeContext>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}

View File

@@ -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<RouterAppContext>()({
head: () => ({
meta: [
@@ -42,16 +47,19 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
function RootDocument() {
return (
<html lang="en" className="dark">
<html lang="en">
<head>
<HeadContent />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>
<ThemeProvider>
<div className="grid min-h-svh grid-rows-[auto_1fr]">
<Header />
<Outlet />
</div>
<Toaster richColors />
</ThemeProvider>
<TanStackRouterDevtools position="bottom-left" />
<Scripts />
</body>

View File

@@ -5,6 +5,7 @@ import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
import { timelineQueryOptions } from "@/functions/get-timeline";
import { FlutterView } from "@/components/flutter-view";
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
import { useTheme } from "@/lib/theme";
export const Route = createFileRoute("/timeline/$timelineId")({
loader: async ({ context, params }) => {
@@ -21,6 +22,7 @@ function RouteComponent() {
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
const entryMoved = useEntryMovedMutation(timelineId);
const { theme } = useTheme();
const flutterState: FlutterTimelineState = useMemo(
() => ({
@@ -42,8 +44,9 @@ function RouteComponent() {
),
groupOrder: timeline.groupOrder,
selectedItemId,
darkMode: theme === "dark",
}),
[timeline, selectedItemId]
[timeline, selectedItemId, theme]
);
const handleEvent = useCallback(

View File

@@ -20,6 +20,7 @@ class _MainAppState extends State<MainApp> {
List<TimelineGroup> _groups = const [];
List<TimelineEntry> _entries = const [];
TimelineViewportNotifier? _viewport;
bool _darkMode = true;
@override
void initState() {
@@ -53,6 +54,7 @@ class _MainAppState extends State<MainApp> {
_state = state;
_groups = groups;
_entries = entries;
_darkMode = state.darkMode;
_viewport ??= TimelineViewportNotifier(
start: domain.start,
@@ -207,7 +209,9 @@ class _MainAppState extends State<MainApp> {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true),
theme: _darkMode
? ThemeData.dark(useMaterial3: true)
: ThemeData.light(useMaterial3: true),
home: Scaffold(
backgroundColor: Colors.transparent,
body: _state == null || viewport == null

View File

@@ -11,6 +11,7 @@ class TimelineState {
final Map<String, TimelineItemData> items;
final List<String> groupOrder;
final String? selectedItemId;
final bool darkMode;
TimelineState({
required this.timeline,
@@ -18,6 +19,7 @@ class TimelineState {
required this.items,
required this.groupOrder,
this.selectedItemId,
this.darkMode = true,
});
factory TimelineState.fromJson(Map<String, dynamic> json) {
@@ -36,6 +38,7 @@ class TimelineState {
),
groupOrder: (json['groupOrder'] as List<dynamic>).cast<String>(),
selectedItemId: json['selectedItemId'] as String?,
darkMode: json['darkMode'] as bool? ?? true,
);
}
}

17
pnpm-lock.yaml generated
View File

@@ -123,9 +123,6 @@ importers:
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.2.3)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
@@ -266,6 +263,9 @@ importers:
eslint:
specifier: ^9.17.0
version: 9.39.2(jiti@2.6.1)
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: 'catalog:'
version: 5.9.3
@@ -3561,12 +3561,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -7774,11 +7768,6 @@ snapshots:
negotiator@1.0.0: {}
next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
node-domexception@1.0.0: {}
node-fetch@3.3.2: