fix: improve the ui

This commit is contained in:
2026-08-19 21:59:29 +07:00
parent d4a7ead7f3
commit e3e641444c
8 changed files with 507 additions and 157 deletions
+6 -3
View File
@@ -1,10 +1,13 @@
import { QueryProvider } from "@/providers/QueryProvider"
import { ThemeProvider } from "@/providers/ThemeProvider"
import { AppRouter } from "@/router"
export default function App() {
return (
<QueryProvider>
<AppRouter />
</QueryProvider>
<ThemeProvider defaultTheme="system">
<QueryProvider>
<AppRouter />
</QueryProvider>
</ThemeProvider>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/providers/ThemeProvider"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface ThemeToggleProps {
className?: string
showLabel?: boolean
variant?: "outline" | "ghost" | "default" | "secondary"
size?: "default" | "sm" | "lg" | "icon"
}
export function ThemeToggle({
className,
showLabel = false,
variant = "outline",
size = "icon",
}: ThemeToggleProps) {
const { theme, resolvedTheme, toggleTheme } = useTheme()
const isDark = resolvedTheme === "dark"
return (
<Button
variant={variant}
size={size}
onClick={toggleTheme}
className={cn(
"h-9 w-9 rounded-xl transition-all duration-200 cursor-pointer shrink-0",
className
)}
title={`Current: ${theme} (click to switch to ${isDark ? "light" : "dark"} mode)`}
aria-label={`Current theme: ${theme}. Click to switch to ${isDark ? "light" : "dark"} mode.`}
>
<div className="relative flex items-center justify-center">
{isDark ? (
<Moon className="h-4 w-4 text-cyan-400 transition-transform duration-200 hover:scale-110" />
) : (
<Sun className="h-4 w-4 text-amber-500 transition-transform duration-200 hover:scale-110" />
)}
</div>
{showLabel && (
<span className="capitalize hidden min-[360px]:inline ml-1.5 text-xs">
{isDark ? "Dark" : "Light"}
</span>
)}
</Button>
)
}
@@ -0,0 +1,232 @@
import { Activity, AlertTriangle, Server, HardDrive, Cpu, AlertCircle, User, CheckCircle2 } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { formatBytes } from "@/lib/format"
import type { StatusResponse } from "../api/status.types"
interface SystemOverviewCardProps {
status?: StatusResponse
isLoading: boolean
error?: Error | null
}
export function SystemOverviewCard({ status, isLoading, error }: SystemOverviewCardProps) {
if (isLoading) {
return (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md py-4 sm:py-5">
<CardContent className="px-4 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6 divide-y md:divide-y-0 md:divide-x divide-border/60">
{/* Gateway Skeleton */}
<div className="space-y-2.5 pb-3 md:pb-0">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-7 w-36" />
<Skeleton className="h-4 w-44" />
</div>
{/* Storage Skeleton */}
<div className="space-y-2.5 pt-3 md:pt-0 md:px-6">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-7 w-28" />
<Skeleton className="h-2 w-full" />
</div>
{/* Multipart Skeleton */}
<div className="space-y-2.5 pt-3 md:pt-0 md:pl-6">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-7 w-32" />
<Skeleton className="h-4 w-40" />
</div>
</div>
</CardContent>
</Card>
)
}
if (error || !status) {
return (
<Card className="relative overflow-hidden border-destructive/40 bg-card/90 backdrop-blur-sm shadow-md py-4 sm:py-5">
<div className="absolute top-0 left-0 right-0 h-1 bg-destructive" />
<CardContent className="px-4 sm:px-6">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-destructive/10 text-destructive shrink-0">
<AlertTriangle className="h-5 w-5" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-bold text-destructive">Gateway Service Unavailable</h3>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{error?.message || "Failed to query live status from Cloudflare Worker"}
</p>
</div>
</div>
</CardContent>
</Card>
)
}
const isDegraded = status.gateway.status === "degraded" || !status.drive.connected
const drive = status.drive
const quota = drive.quota
const isUnlimited = quota?.limit == null
const percentUsed = quota?.percentUsed ?? 0
const isMultipartEnabled = status.gateway.multipartEnabled ?? false
const etagStyle = status.gateway.etagStyle ?? "md5"
return (
<Card className="relative overflow-hidden border-border/80 bg-card/90 backdrop-blur-sm shadow-md hover:shadow-lg transition-all py-4 sm:py-5 group">
{/* Top accent bar with unified status gradient */}
<div
className={`absolute top-0 left-0 right-0 h-1 ${
isDegraded
? "bg-gradient-to-r from-amber-500 to-red-500"
: "bg-gradient-to-r from-emerald-500 via-blue-500 to-cyan-500"
}`}
/>
<CardContent className="px-4 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-border/60">
{/* Section 1: Gateway Engine */}
<div className="flex flex-col justify-between space-y-2 pb-4 md:pb-0 md:pr-6">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
Gateway Engine
</span>
<div
className={`p-1.5 rounded-lg ${
isDegraded
? "bg-amber-500/10 text-amber-500"
: "bg-emerald-500/10 text-emerald-500"
} group-hover:scale-105 transition-transform`}
>
<Server className="h-3.5 w-3.5" />
</div>
</div>
<div>
<div className="text-xl sm:text-2xl font-bold flex items-center gap-2 text-foreground">
<span
className={`h-2.5 w-2.5 rounded-full ${
isDegraded
? "bg-amber-500 shadow-xs shadow-amber-500/50"
: "bg-emerald-500 shadow-xs shadow-emerald-500/50 animate-pulse"
}`}
/>
<span className="truncate">{isDegraded ? "Degraded" : "Online & Healthy"}</span>
</div>
</div>
<div className="text-xs text-muted-foreground flex items-center justify-between gap-1 pt-1">
<div className="flex items-center gap-1.5 min-w-0">
{isDegraded ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0" />
) : (
<Activity className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
)}
<span className="truncate">
{isDegraded
? drive.error || "Drive connection degraded"
: `Edge routing · Region: ${status.gateway.region}`}
</span>
</div>
{status.gateway.docsEnabled && (
<Badge variant="neutral" className="text-[10px] px-1.5 py-0 shrink-0">
Docs
</Badge>
)}
</div>
</div>
{/* Section 2: S3 Storage Backend (Google Drive) */}
<div className="flex flex-col justify-between space-y-2 py-4 md:py-0 md:px-6">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
S3 Storage Backend
</span>
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-500 group-hover:scale-105 transition-transform">
<HardDrive className="h-3.5 w-3.5" />
</div>
</div>
{!drive.connected ? (
<div className="space-y-1">
<div className="text-xl font-bold text-foreground">Google Drive</div>
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{drive.error || "Drive connection error"}</span>
</p>
</div>
) : (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2">
<div className="text-xl sm:text-2xl font-bold text-foreground">
{formatBytes(quota?.usage)}
</div>
<div className="text-xs text-muted-foreground font-medium">
{isUnlimited ? "Unlimited" : `/ ${formatBytes(quota?.limit)}`}
</div>
</div>
{!isUnlimited && (
<div className="space-y-1">
<Progress
value={percentUsed}
indicatorClassName={
percentUsed > 90
? "bg-red-500"
: percentUsed > 75
? "bg-amber-500"
: "bg-gradient-to-r from-blue-600 to-cyan-500"
}
/>
<div className="flex justify-between text-[11px] text-muted-foreground">
<span>{percentUsed}% used</span>
<span>{formatBytes(quota?.free)} free</span>
</div>
</div>
)}
</div>
)}
<div className="text-xs text-muted-foreground flex items-center gap-1.5 pt-0.5 truncate">
<User className="h-3.5 w-3.5 text-blue-500 shrink-0" />
<span className="truncate">
{drive.account?.email || drive.account?.displayName || "Google Drive"}
</span>
</div>
</div>
{/* Section 3: Multipart Uploads */}
<div className="flex flex-col justify-between space-y-2 pt-4 md:pt-0 md:pl-6">
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
Multipart Uploads
</span>
<div className="p-1.5 rounded-lg bg-cyan-500/10 text-cyan-500 group-hover:scale-105 transition-transform">
<Cpu className="h-3.5 w-3.5" />
</div>
</div>
<div>
<div className="text-xl sm:text-2xl font-bold text-foreground flex items-center justify-between">
<span>{isMultipartEnabled ? "Enabled" : "Disabled"}</span>
<Badge variant={isMultipartEnabled ? "success" : "neutral"} className="text-[10px] px-2 py-0.5">
{etagStyle.toUpperCase()} ETag
</Badge>
</div>
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1.5 pt-1">
<CheckCircle2 className="h-3.5 w-3.5 text-cyan-500 shrink-0" />
<span className="truncate">
{isMultipartEnabled
? "Durable Objects resumable state"
: "ALLOW_MULTIPART disabled"}
</span>
</p>
</div>
</div>
</CardContent>
</Card>
)
}
+16
View File
@@ -73,6 +73,16 @@
--radius-xl: calc(var(--radius) + 4px);
}
html {
font-size: 16.5px;
}
@media (min-width: 768px) {
html {
font-size: 17px;
}
}
body {
margin: 0;
min-height: 100vh;
@@ -91,6 +101,12 @@ body {
linear-gradient(to bottom, rgba(99, 102, 241, 0.05) 1px, transparent 1px);
}
.dark .bg-grid-pattern {
background-image:
linear-gradient(to right, rgba(99, 102, 241, 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(99, 102, 241, 0.08) 1px, transparent 1px);
}
.bg-gradient-brand {
background: linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%);
}
+9 -28
View File
@@ -13,11 +13,10 @@ import { useAuth } from "@/features/auth/hooks/useAuth"
import { useStatus } from "@/features/status/hooks/useStatus"
import { useBucketStats } from "@/features/status/hooks/useBucketStats"
import { statusKeys } from "@/features/status/api/status.keys"
import { GatewayStatusCard } from "@/features/status/components/GatewayStatusCard"
import { DriveQuotaCard } from "@/features/status/components/DriveQuotaCard"
import { MultipartStatusCard } from "@/features/status/components/MultipartStatusCard"
import { SystemOverviewCard } from "@/features/status/components/SystemOverviewCard"
import { BucketStatsTable } from "@/features/status/components/BucketStatsTable"
import { Button } from "@/components/ui/button"
import { ThemeToggle } from "@/components/ThemeToggle"
import { formatRelativeTime } from "@/lib/format"
export default function Dashboard() {
@@ -62,13 +61,6 @@ export default function Dashboard() {
return (
<div className="relative min-h-screen bg-background bg-grid-pattern flex flex-col overflow-x-hidden">
{/* Background ambient colorful glow effects */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-[15%] -left-[10%] h-[500px] w-[500px] rounded-full bg-blue-500/10 blur-[130px]" />
<div className="absolute top-[20%] -right-[15%] h-[550px] w-[550px] rounded-full bg-cyan-400/10 blur-[140px]" />
<div className="absolute -bottom-[20%] left-[30%] h-[400px] w-[400px] rounded-full bg-indigo-500/10 blur-[130px]" />
</div>
{/* Navigation Header */}
<header className="border-b border-border/70 bg-card/80 backdrop-blur-md sticky top-0 z-20 shadow-xs">
<div className="max-w-6xl mx-auto px-3 sm:px-6 min-h-16 py-2.5 sm:py-0 flex items-center justify-between gap-3">
@@ -106,6 +98,7 @@ export default function Dashboard() {
<Lock className="h-3.5 w-3.5 text-emerald-500" />
<span className="font-medium text-foreground/80">Admin Mode</span>
</div>
<ThemeToggle showLabel={false} />
<Button
variant="outline"
size="sm"
@@ -170,24 +163,12 @@ export default function Dashboard() {
</div>
</div>
{/* Status / Quick Overview cards */}
<div className="grid gap-4 sm:grid-cols-3">
<GatewayStatusCard
status={statusData}
isLoading={isStatusLoading}
error={statusError}
/>
<DriveQuotaCard
drive={statusData?.drive}
isLoading={isStatusLoading}
error={statusError}
/>
<MultipartStatusCard
gateway={statusData?.gateway}
isLoading={isStatusLoading}
error={statusError}
/>
</div>
{/* Unified Status / Quick Overview */}
<SystemOverviewCard
status={statusData}
isLoading={isStatusLoading}
error={statusError}
/>
{/* Feature Capabilities & Specifications */}
<div className="w-full">
+89 -126
View File
@@ -1,19 +1,12 @@
import React, { useState } from "react"
import { useNavigate, Navigate } from "react-router"
import { Eye, EyeOff, Lock, Database, ArrowRight, Loader2, AlertCircle, Sparkles, Shield, Cloud } from "lucide-react"
import { Eye, EyeOff, Lock, Database, ArrowRight, Loader2, AlertCircle } from "lucide-react"
import { useAuth } from "@/features/auth/hooks/useAuth"
import { ApiError } from "@/lib/api-client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ThemeToggle } from "@/components/ThemeToggle"
function getErrorMessage(error: Error | null): string | null {
if (!error) return null
@@ -75,134 +68,104 @@ export default function SignIn() {
}
return (
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background bg-grid-pattern p-4 sm:p-6 lg:p-8 overflow-hidden">
{/* Background ambient colorful glow effects */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-[20%] -left-[10%] h-[550px] w-[550px] rounded-full bg-blue-500/15 blur-[130px]" />
<div className="absolute top-[30%] -right-[15%] h-[500px] w-[500px] rounded-full bg-cyan-400/15 blur-[120px]" />
<div className="absolute -bottom-[20%] left-[25%] h-[450px] w-[450px] rounded-full bg-indigo-500/12 blur-[140px]" />
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background bg-grid-pattern p-4 overflow-hidden">
{/* Theme Toggle in top-right */}
<div className="absolute top-4 right-4 z-20">
<ThemeToggle showLabel={false} />
</div>
<div className="relative w-full max-w-md">
{/* Logo and Brand Header */}
<div className="mb-8 text-center flex flex-col items-center">
<div className="relative mb-4 group cursor-default">
<div className="absolute -inset-1 rounded-2xl bg-gradient-to-r from-blue-600 via-indigo-500 to-cyan-400 opacity-70 blur-sm group-hover:opacity-100 transition duration-300" />
<div className="relative inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-tr from-blue-600 via-indigo-600 to-cyan-500 text-white shadow-xl shadow-blue-500/25">
<Database className="h-8 w-8 text-white stroke-[2.2]" />
</div>
<div className="absolute -top-1.5 -right-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500 text-white ring-2 ring-background shadow-xs">
<Cloud className="h-3.5 w-3.5" />
</div>
<div className="w-full max-w-sm space-y-6">
{/* Brand & Header */}
<div className="text-center space-y-2">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-xs mb-1">
<Database className="h-5 w-5" />
</div>
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400 text-xs font-semibold mb-3">
<Sparkles className="h-3.5 w-3.5 text-blue-500" />
<span>Cloudflare Worker &amp; Drive Bridge</span>
</div>
<h1 className="text-3xl font-extrabold tracking-tight text-foreground sm:text-4xl">
S3 Drive <span className="bg-gradient-to-r from-blue-600 via-indigo-600 to-cyan-500 bg-clip-text text-transparent">Storage</span>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
S3 Drive Storage
</h1>
<p className="mt-2 text-sm text-muted-foreground font-medium">
High-performance management console for Google Drive S3 gateway
<p className="text-xs text-muted-foreground">
Enter your management password to access the console
</p>
</div>
{/* Sign In Card */}
<Card className="border-border/70 bg-card/85 backdrop-blur-xl shadow-2xl shadow-blue-950/5 ring-1 ring-black/5 dark:ring-white/10 rounded-2xl overflow-hidden">
<div className="h-1.5 w-full bg-gradient-to-r from-blue-500 via-indigo-500 to-cyan-400" />
<CardHeader className="space-y-1.5 pt-6 pb-4">
<CardTitle className="text-xl font-bold text-center tracking-tight">
Sign In to Console
</CardTitle>
<CardDescription className="text-center text-xs text-muted-foreground">
Enter your gateway access password to unlock management tools
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4 px-6">
{/* Error Alert */}
{error && (
<div
className="flex items-start gap-2.5 rounded-xl border border-destructive/20 bg-destructive/10 p-3.5 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200"
role="alert"
>
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
<div className="leading-relaxed font-medium">{error}</div>
</div>
)}
{/* Password Input Field */}
<div className="space-y-2">
<Label htmlFor="password" className="text-xs font-semibold text-foreground/80">
Access Password
</Label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 flex items-center pl-3.5 pointer-events-none text-muted-foreground group-focus-within:text-blue-600 transition-colors">
<Lock className="h-4 w-4" />
</div>
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (signInError) resetSignInError()
}}
placeholder="Enter management password"
className="pl-10 pr-10 h-11 text-sm bg-background/80 border-border/80 rounded-xl focus-visible:ring-2 focus-visible:ring-blue-500/40 focus-visible:border-blue-500 transition-all"
disabled={isSigningIn}
autoComplete="current-password"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3.5 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</CardContent>
<CardFooter className="pt-3 pb-7 px-6 flex flex-col gap-3">
<Button
type="submit"
className="w-full h-11 font-semibold rounded-xl text-sm"
disabled={isSigningIn || !password.trim()}
<div className="rounded-2xl border border-border/80 bg-card/90 backdrop-blur-md p-6 shadow-xl shadow-black/5 dark:shadow-black/30">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Error Alert */}
{error && (
<div
className="flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/10 p-3 text-xs text-destructive animate-in fade-in duration-200"
role="alert"
>
{isSigningIn ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Verifying Credentials...
</>
) : (
<>
Unlock Console
<ArrowRight className="h-4 w-4 ml-1 transition-transform group-hover:translate-x-1" />
</>
)}
</Button>
</CardFooter>
</form>
</Card>
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
<div className="leading-relaxed font-medium">{error}</div>
</div>
)}
{/* Security badge footer */}
<div className="mt-8 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Shield className="h-3.5 w-3.5 text-emerald-500" />
<span>Protected by S3 Drive Storage Gateway SHA-256 Auth</span>
{/* Password Input Field */}
<div className="space-y-1.5">
<Label htmlFor="password" className="text-xs font-medium text-muted-foreground">
Access Password
</Label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-muted-foreground">
<Lock className="h-4 w-4" />
</div>
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (signInError) resetSignInError()
}}
placeholder="••••••••••••"
className="pl-9 pr-9 h-10 text-sm bg-background/60 border-border rounded-lg focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary transition-all"
disabled={isSigningIn}
autoComplete="current-password"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button
type="submit"
className="w-full h-10 font-medium rounded-lg text-sm mt-2"
disabled={isSigningIn || !password.trim()}
>
{isSigningIn ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Authenticating...
</>
) : (
<>
Sign In
<ArrowRight className="h-4 w-4 ml-1" />
</>
)}
</Button>
</form>
</div>
{/* Minimal Footer */}
<p className="text-center text-[11px] text-muted-foreground/70">
Protected by S3 Drive Gateway Auth
</p>
</div>
</div>
)
+105
View File
@@ -0,0 +1,105 @@
import * as React from "react"
export type Theme = "light" | "dark" | "system"
interface ThemeProviderProps {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
interface ThemeContextType {
theme: Theme
resolvedTheme: "light" | "dark"
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "s3-drive-ui-theme",
}: ThemeProviderProps) {
const [theme, setThemeState] = React.useState<Theme>(() => {
try {
const stored = localStorage.getItem(storageKey) as Theme | null
if (stored === "light" || stored === "dark" || stored === "system") {
return stored
}
} catch {
// localStorage may be unavailable in some private browsing modes
}
return defaultTheme
})
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
if (theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
}
return theme
})
React.useEffect(() => {
const root = document.documentElement
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
const applyTheme = () => {
const currentResolved =
theme === "system" ? (mediaQuery.matches ? "dark" : "light") : theme
setResolvedTheme(currentResolved)
root.classList.remove("light", "dark")
root.classList.add(currentResolved)
root.style.colorScheme = currentResolved
}
applyTheme()
const listener = () => {
if (theme === "system") {
applyTheme()
}
}
mediaQuery.addEventListener("change", listener)
return () => mediaQuery.removeEventListener("change", listener)
}, [theme])
const setTheme = React.useCallback(
(newTheme: Theme) => {
try {
localStorage.setItem(storageKey, newTheme)
} catch {
// ignore storage write errors
}
setThemeState(newTheme)
},
[storageKey]
)
const toggleTheme = React.useCallback(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}, [resolvedTheme, setTheme])
const value = React.useMemo(
() => ({
theme,
resolvedTheme,
setTheme,
toggleTheme,
}),
[theme, resolvedTheme, setTheme, toggleTheme]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme(): ThemeContextType {
const context = React.useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider")
}
return context
}