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:", "dotenv": "catalog:",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next-themes": "^0.4.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shadcn": "^3.6.2", "shadcn": "^3.6.2",

View File

@@ -1,8 +1,12 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Moon, Sun } from "lucide-react";
import UserMenu from "./user-menu"; import UserMenu from "./user-menu";
import { useTheme } from "@/lib/theme";
export default function Header() { export default function Header() {
const { theme, toggleTheme } = useTheme();
const links = [ const links = [
{ to: "/", label: "Home" }, { to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" }, { to: "/dashboard", label: "Dashboard" },
@@ -22,6 +26,20 @@ export default function Header() {
})} })}
</nav> </nav>
<div className="flex items-center gap-2"> <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 /> <UserMenu />
</div> </div>
</div> </div>

View File

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

View File

@@ -37,6 +37,7 @@ export type FlutterTimelineState = {
items: Record<string, FlutterTimelineItem>; items: Record<string, FlutterTimelineItem>;
groupOrder: Array<string>; groupOrder: Array<string>;
selectedItemId: string | null; 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 appCss from "../index.css?url";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/lib/theme";
export interface RouterAppContext { export interface RouterAppContext {
queryClient: QueryClient; 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>()({ export const Route = createRootRouteWithContext<RouterAppContext>()({
head: () => ({ head: () => ({
meta: [ meta: [
@@ -42,16 +47,19 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
function RootDocument() { function RootDocument() {
return ( return (
<html lang="en" className="dark"> <html lang="en">
<head> <head>
<HeadContent /> <HeadContent />
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head> </head>
<body> <body>
<div className="grid min-h-svh grid-rows-[auto_1fr]"> <ThemeProvider>
<Header /> <div className="grid min-h-svh grid-rows-[auto_1fr]">
<Outlet /> <Header />
</div> <Outlet />
<Toaster richColors /> </div>
<Toaster richColors />
</ThemeProvider>
<TanStackRouterDevtools position="bottom-left" /> <TanStackRouterDevtools position="bottom-left" />
<Scripts /> <Scripts />
</body> </body>

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ class TimelineState {
final Map<String, TimelineItemData> items; final Map<String, TimelineItemData> items;
final List<String> groupOrder; final List<String> groupOrder;
final String? selectedItemId; final String? selectedItemId;
final bool darkMode;
TimelineState({ TimelineState({
required this.timeline, required this.timeline,
@@ -18,6 +19,7 @@ class TimelineState {
required this.items, required this.items,
required this.groupOrder, required this.groupOrder,
this.selectedItemId, this.selectedItemId,
this.darkMode = true,
}); });
factory TimelineState.fromJson(Map<String, dynamic> json) { factory TimelineState.fromJson(Map<String, dynamic> json) {
@@ -36,6 +38,7 @@ class TimelineState {
), ),
groupOrder: (json['groupOrder'] as List<dynamic>).cast<String>(), groupOrder: (json['groupOrder'] as List<dynamic>).cast<String>(),
selectedItemId: json['selectedItemId'] as 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: lucide-react:
specifier: ^0.525.0 specifier: ^0.525.0
version: 0.525.0(react@19.2.3) 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: react:
specifier: 19.2.3 specifier: 19.2.3
version: 19.2.3 version: 19.2.3
@@ -266,6 +263,9 @@ importers:
eslint: eslint:
specifier: ^9.17.0 specifier: ^9.17.0
version: 9.39.2(jiti@2.6.1) version: 9.39.2(jiti@2.6.1)
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript: typescript:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.9.3 version: 5.9.3
@@ -3561,12 +3561,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} 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: node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'} engines: {node: '>=10.5.0'}
@@ -7774,11 +7768,6 @@ snapshots:
negotiator@1.0.0: {} 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-domexception@1.0.0: {}
node-fetch@3.3.2: node-fetch@3.3.2: