add dark theme toggle
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -37,6 +37,7 @@ export type FlutterTimelineState = {
|
||||
items: Record<string, FlutterTimelineItem>;
|
||||
groupOrder: Array<string>;
|
||||
selectedItemId: string | null;
|
||||
darkMode: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
73
apps/web/src/lib/theme.tsx
Normal file
73
apps/web/src/lib/theme.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
<div className="grid min-h-svh grid-rows-[auto_1fr]">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
<ThemeProvider>
|
||||
<div className="grid min-h-svh grid-rows-[auto_1fr]">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
<TanStackRouterDevtools position="bottom-left" />
|
||||
<Scripts />
|
||||
</body>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
17
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user