feat: add button for toggle theme

This commit is contained in:
2026-09-03 16:12:19 +07:00
parent 6d8c40b37d
commit f6203263e4
9 changed files with 152 additions and 11 deletions
+20
View File
@@ -5,7 +5,27 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Upwatch — fast, dependable uptime monitoring from Cloudflare's edge." />
<meta name="robots" content="noindex,nofollow" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#ffffff" />
<script>
(() => {
try {
const storedTheme = localStorage.getItem('upwatch-theme');
const theme =
storedTheme === 'light' || storedTheme === 'dark'
? storedTheme
: matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'dark' ? '#171717' : '#ffffff');
} catch {
// Keep the default light theme when browser storage is unavailable.
}
})();
</script>
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+3
View File
@@ -2,6 +2,7 @@ import { Activity, LogOut, Menu, Settings, Zap, type LucideIcon } from 'lucide-r
import { Button } from '@/components/ui/button';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ThemeToggle } from './ThemeToggle';
import { navigate, usePathname } from '../lib/router';
import { useLogoutMutation } from '../queries/auth';
@@ -54,6 +55,7 @@ export function AppHeader({ context }: { context?: string }) {
<TooltipProvider>
<nav className="nav-actions" aria-label="Primary navigation">
{context && <span className="header-context">{context}</span>}
<ThemeToggle className="app-nav-icon" />
{items.map((item) => {
const ItemIcon = item.icon;
const isCurrent = item.href === pathname;
@@ -97,6 +99,7 @@ export function AppHeader({ context }: { context?: string }) {
{context && <SheetDescription>{context}</SheetDescription>}
</SheetHeader>
<nav className="app-nav-mobile" aria-label="Primary navigation">
<ThemeToggle className="app-nav-mobile-item" showLabel />
{items.map((item) => {
const ItemIcon = item.icon;
const isCurrent = item.href === pathname;
+38
View File
@@ -0,0 +1,38 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useTheme } from '../lib/theme';
type ThemeToggleProps = {
className?: string;
showLabel?: boolean;
};
export function ThemeToggle({ className = '', showLabel = false }: ThemeToggleProps) {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
const Icon = isDark ? Sun : Moon;
if (showLabel) {
return (
<Button variant="unstyled" className={className} type="button" onClick={toggleTheme} aria-label={label}>
<Icon aria-hidden="true" />
<span>{label}</span>
</Button>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={className} type="button" onClick={toggleTheme} aria-label={label}>
<Icon aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useSyncExternalStore } from 'react';
export type Theme = 'light' | 'dark';
const THEME_STORAGE_KEY = 'upwatch-theme';
const listeners = new Set<() => void>();
let currentTheme: Theme = 'light';
function getPreferredTheme(): Theme {
try {
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'light' || storedTheme === 'dark') return storedTheme;
} catch {
// Storage can be unavailable in privacy or sandboxed contexts.
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme: Theme) {
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.style.colorScheme = theme;
document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')?.setAttribute('content', theme === 'dark' ? '#171717' : '#ffffff');
}
export function initializeTheme() {
currentTheme = getPreferredTheme();
applyTheme(currentTheme);
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getThemeSnapshot() {
return currentTheme;
}
export function useTheme() {
const theme = useSyncExternalStore(subscribe, getThemeSnapshot, () => 'light');
function toggleTheme() {
const nextTheme = theme === 'dark' ? 'light' : 'dark';
currentTheme = nextTheme;
applyTheme(nextTheme);
listeners.forEach((listener) => listener());
try {
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
} catch {
// The active theme still works for this page when persistence is unavailable.
}
}
return { theme, toggleTheme };
}
+3
View File
@@ -3,6 +3,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { queryClient } from './lib/query-client';
import { initializeTheme } from './lib/theme';
import './styles.css';
const root = document.getElementById('root');
@@ -11,6 +12,8 @@ if (!root) {
throw new Error('Root element was not found');
}
initializeTheme();
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
+4
View File
@@ -2,6 +2,7 @@ import { ArrowLeft, TriangleAlert, Zap } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { IncidentTimeline } from '../components/IncidentTimeline';
import { ThemeToggle } from '../components/ThemeToggle';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { usePublicIncidentQuery } from '../queries/status';
@@ -32,10 +33,13 @@ export function IncidentDetailPage({ id }: { id: number }) {
<a className="brand" href="/" aria-label="Upwatch public status">
<Zap className="brand-mark" fill="currentColor" /> <span>upwatch</span>
</a>
<div className="status-header-actions">
<ThemeToggle className="app-nav-icon" />
<Button variant="unstyled" className="status-header-action" onClick={() => navigate('/')}>
Status page
</Button>
</div>
</div>
</header>
<main className="status-main incident-detail-page">
<button className="incident-back" type="button" onClick={() => navigate('/')}>
+2
View File
@@ -3,6 +3,7 @@ import { Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { ThemeToggle } from '../components/ThemeToggle';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useLoginMutation, useSessionQuery } from '../queries/auth';
@@ -26,6 +27,7 @@ export function LoginPage() {
return (
<main className="auth-page">
<ThemeToggle className="auth-theme-toggle app-nav-icon" />
<div className="auth-brand" aria-label="Upwatch">
<Zap className="auth-brand-mark" fill="currentColor" />
<span>upwatch</span>
+4
View File
@@ -6,6 +6,7 @@ import { Empty, EmptyContent, EmptyDescription, EmptyMedia, EmptyTitle } from '@
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
import { SiteIcon } from '../components/SiteIcon';
import { StatusHistoryBar } from '../components/StatusHistoryBar';
import { ThemeToggle } from '../components/ThemeToggle';
import { formatDate, formatDuration } from '../lib/format';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
@@ -96,6 +97,8 @@ export function StatusPage() {
<Zap className="brand-mark" fill="currentColor" />
<span>upwatch</span>
</a>
<div className="status-header-actions">
<ThemeToggle className="app-nav-icon" />
<Button
variant="unstyled"
className="status-header-action"
@@ -105,6 +108,7 @@ export function StatusPage() {
{sessionQuery.data?.authenticated ? 'Dashboard' : 'Sign in'}
</Button>
</div>
</div>
</header>
<main className="status-main">
+12
View File
@@ -873,6 +873,7 @@ button {
}
.auth-page {
position: relative;
display: grid;
grid-template-rows: auto 1fr auto;
justify-items: center;
@@ -880,6 +881,12 @@ button {
padding: 32px 24px 24px;
background: radial-gradient(circle at 50% 38%, rgb(62 207 142 / 0.08), transparent 34%), linear-gradient(#fff, #fcfcfc);
}
.auth-theme-toggle {
position: absolute;
z-index: 1;
top: 28px;
right: 28px;
}
.auth-page::before {
position: fixed;
inset: 0;
@@ -2102,6 +2109,11 @@ button {
.status-header-inner {
width: min(960px, calc(100% - 48px));
}
.status-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.status-header-action {
min-height: 34px;
padding: 6px 13px;