add dark theme toggle
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
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 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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
17
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user