feat: dark mode with theme toggle button

This commit is contained in:
2025-09-13 21:11:16 -05:00
parent a732ff9a15
commit 398a1b9474
9 changed files with 161 additions and 39 deletions

View File

@@ -16,6 +16,7 @@
"@tanstack/react-router-devtools": "^1.131.5", "@tanstack/react-router-devtools": "^1.131.5",
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-timeago": "^8.3.0", "react-timeago": "^8.3.0",

14
web/pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.544.0 specifier: ^0.544.0
version: 0.544.0(react@19.1.1) version: 0.544.0(react@19.1.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.1.1 version: 19.1.1
@@ -1865,6 +1868,12 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
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-releases@2.0.21: node-releases@2.0.21:
resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==}
@@ -4133,6 +4142,11 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
node-releases@2.0.21: {} node-releases@2.0.21: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}

View File

@@ -1,9 +1,10 @@
.App { .App {
background-color: #f8fafc;
min-height: 100vh; min-height: 100vh;
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background-color: var(--color-background);
color: var(--color-text);
} }
@keyframes pulse { @keyframes pulse {
@@ -19,3 +20,16 @@
.animate-pulse { .animate-pulse {
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
/* Screen reader only text */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -0,0 +1,60 @@
import { useTheme } from "next-themes";
import { Button } from "@radix-ui/themes";
import { Sun, Moon, Monitor } from "lucide-react";
import { useMemo } from "react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const nextTheme = useMemo(() => {
switch (theme) {
case "light":
return "dark";
case "dark":
return "system";
case "system":
return "light";
default:
console.error(`Invalid theme: ${theme}`);
return "system";
}
}, [theme]);
const icon = useMemo(() => {
if (nextTheme === "system") {
return <Monitor size={18} />;
}
return nextTheme === "dark" ? <Moon size={18} /> : <Sun size={18} />;
}, [nextTheme]);
return (
<Button
variant="ghost"
size="3"
onClick={() => setTheme(nextTheme)}
style={{
cursor: "pointer",
backgroundColor: "transparent",
border: "none",
margin: "4px",
padding: "7px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--gray-11)",
transition: "background-color 0.2s, color 0.2s",
transform: "scale(1.25)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--gray-4)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{icon}
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@@ -60,4 +60,4 @@ export class BannerApiClient {
} }
// Export a default instance // Export a default instance
export const apiClient = new BannerApiClient(); export const client = new BannerApiClient();

View File

@@ -1,42 +1,53 @@
import { StrictMode } from 'react' import { StrictMode } from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from '@tanstack/react-router' import { RouterProvider, createRouter } from "@tanstack/react-router";
import { ThemeProvider } from "next-themes";
import { Theme } from "@radix-ui/themes";
// Import the generated route tree // Import the generated route tree
import { routeTree } from './routeTree.gen' import { routeTree } from "./routeTree.gen";
import './styles.css' import "./styles.css";
import reportWebVitals from './reportWebVitals.ts' import reportWebVitals from "./reportWebVitals.ts";
// Create a new router instance // Create a new router instance
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
context: {}, context: {},
defaultPreload: 'intent', defaultPreload: "intent",
scrollRestoration: true, scrollRestoration: true,
defaultStructuralSharing: true, defaultStructuralSharing: true,
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
}) });
// Register the router instance for type safety // Register the router instance for type safety
declare module '@tanstack/react-router' { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: typeof router router: typeof router;
} }
} }
// Render the app // Render the app
const rootElement = document.getElementById('app') const rootElement = document.getElementById("app");
if (rootElement && !rootElement.innerHTML) { if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement) const root = ReactDOM.createRoot(rootElement);
root.render( root.render(
<StrictMode> <StrictMode>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
>
<Theme>
<RouterProvider router={router} /> <RouterProvider router={router} />
</StrictMode>, </Theme>
) </ThemeProvider>
</StrictMode>
);
} }
// If you want to start measuring performance in your app, pass a function // If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log)) // to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals() reportWebVitals();

View File

@@ -3,10 +3,17 @@ import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanstackDevtools } from "@tanstack/react-devtools"; import { TanstackDevtools } from "@tanstack/react-devtools";
import { Theme } from "@radix-ui/themes"; import { Theme } from "@radix-ui/themes";
import "@radix-ui/themes/styles.css"; import "@radix-ui/themes/styles.css";
import { ThemeProvider } from "next-themes";
export const Route = createRootRoute({ export const Route = createRootRoute({
component: () => ( component: () => (
<Theme appearance="light" accentColor="blue" grayColor="gray"> <ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
>
<Theme accentColor="blue" grayColor="gray">
<Outlet /> <Outlet />
<TanstackDevtools <TanstackDevtools
config={{ config={{
@@ -20,5 +27,6 @@ export const Route = createRootRoute({
]} ]}
/> />
</Theme> </Theme>
</ThemeProvider>
), ),
}); });

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { apiClient, type StatusResponse, type Status } from "../lib/api"; import { client, type StatusResponse, type Status } from "../lib/api";
import { Card, Flex, Text, Tooltip, Skeleton } from "@radix-ui/themes"; import { Card, Flex, Text, Tooltip, Skeleton } from "@radix-ui/themes";
import { import {
CheckCircle, CheckCircle,
@@ -15,6 +15,7 @@ import {
WifiOff, WifiOff,
} from "lucide-react"; } from "lucide-react";
import TimeAgo from "react-timeago"; import TimeAgo from "react-timeago";
import { ThemeToggle } from "../components/ThemeToggle";
import "../App.css"; import "../App.css";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
@@ -211,7 +212,7 @@ function App() {
// Race between the API call and timeout // Race between the API call and timeout
const statusData = await Promise.race([ const statusData = await Promise.race([
apiClient.getStatus(), client.getStatus(),
timeoutPromise, timeoutPromise,
]); ]);
@@ -262,6 +263,18 @@ function App() {
return ( return (
<div className="App"> <div className="App">
{/* Theme Toggle - Fixed position in top right */}
<div
style={{
position: "fixed",
top: "20px",
right: "20px",
zIndex: 1000,
}}
>
<ThemeToggle />
</div>
<Flex <Flex
direction="column" direction="column"
align="center" align="center"

View File

@@ -1,14 +1,15 @@
@import "@radix-ui/themes/styles.css";
body { body {
margin: 0; margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", font-family:
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
sans-serif; "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", font-family:
monospace; source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
} }