fix(css): fill mobile safe-area zones with background color

Use fixed ::before and ::after pseudo-elements to render the
background color in safe-area inset zones, preventing transparent
gaps on notched mobile devices while maintaining body padding for
content flow.
This commit is contained in:
2026-05-24 22:54:03 +08:00
parent 737eb2aea2
commit 91e22e1f89
7 changed files with 213 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import { MoonIcon, SunIcon, MonitorIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useThemeStore, type ThemeMode } from "@/store/themeStore"
import { usePlatform } from "@/hooks/usePlatform"
const next: Record<ThemeMode, ThemeMode> = { system: "dark", dark: "light", light: "system" }
const icon: Record<ThemeMode, typeof SunIcon> = { system: MonitorIcon, dark: MoonIcon, light: SunIcon }
export default function ThemeToggle() {
const mode = useThemeStore((s) => s.mode)
const setMode = useThemeStore((s) => s.setMode)
const platform = usePlatform()
const hideTitle = platform === "mobile"
const Icon = icon[mode]
return (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMode(next[mode])}
{...(!hideTitle && { title: `主题:${mode === "system" ? "跟随系统" : mode === "dark" ? "深色" : "浅色"}` })}
>
<Icon className="size-4" />
</Button>
)
}
+3
View File
@@ -1,6 +1,7 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom" import { NavLink, Outlet, useNavigate } from "react-router-dom"
import { BookOpenIcon, ListIcon, LogOutIcon } from "lucide-react" import { BookOpenIcon, ListIcon, LogOutIcon } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import ThemeToggle from "@/components/ThemeToggle"
import { useAuthStore } from "@/store/authStore" import { useAuthStore } from "@/store/authStore"
import { usePlatform } from "@/hooks/usePlatform" import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile" import { useIsMobile } from "@/hooks/useIsMobile"
@@ -45,6 +46,7 @@ export default function AdminLayout() {
</nav> </nav>
<div className="flex items-center gap-2 border-t px-4 py-3"> <div className="flex items-center gap-2 border-t px-4 py-3">
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{username}</span> <span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{username}</span>
<ThemeToggle />
<Button variant="ghost" size="icon-sm" onClick={handleLogout} {...(!hideTitle && { title: "退出" })}> <Button variant="ghost" size="icon-sm" onClick={handleLogout} {...(!hideTitle && { title: "退出" })}>
<LogOutIcon className="size-4" /> <LogOutIcon className="size-4" />
</Button> </Button>
@@ -63,6 +65,7 @@ export default function AdminLayout() {
<span className="font-heading text-sm font-semibold"></span> <span className="font-heading text-sm font-semibold"></span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{username}</span> <span className="text-sm text-muted-foreground">{username}</span>
<ThemeToggle />
<Button variant="ghost" size="icon-sm" onClick={handleLogout}> <Button variant="ghost" size="icon-sm" onClick={handleLogout}>
<LogOutIcon className="size-4" /> <LogOutIcon className="size-4" />
</Button> </Button>
+2
View File
@@ -1,6 +1,7 @@
import { Link, Outlet, useNavigate } from "react-router-dom" import { Link, Outlet, useNavigate } from "react-router-dom"
import { useAuthStore } from "@/store/authStore" import { useAuthStore } from "@/store/authStore"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import ThemeToggle from "@/components/ThemeToggle"
export default function AppLayout() { export default function AppLayout() {
const token = useAuthStore((s) => s.token) const token = useAuthStore((s) => s.token)
@@ -31,6 +32,7 @@ export default function AppLayout() {
)} )}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<ThemeToggle />
{token ? ( {token ? (
<> <>
<span className="text-sm text-muted-foreground">{username}</span> <span className="text-sm text-muted-foreground">{username}</span>
+109
View File
@@ -0,0 +1,109 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom"
import { BookOpenIcon, BookMarkedIcon, LogOutIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import ThemeToggle from "@/components/ThemeToggle"
import { useAuthStore } from "@/store/authStore"
import { usePlatform } from "@/hooks/usePlatform"
import { useIsMobile } from "@/hooks/useIsMobile"
const navItems = [
{ to: "/books", label: "书目", icon: BookOpenIcon },
{ to: "/my-borrows", label: "我的借阅", icon: BookMarkedIcon },
]
export default function UserLayout() {
const username = useAuthStore((s) => s.username)
const token = useAuthStore((s) => s.token)
const clearAuth = useAuthStore((s) => s.clearAuth)
const navigate = useNavigate()
const platform = usePlatform()
const isMobile = useIsMobile()
const hideTitle = platform === "mobile"
function handleLogout() {
clearAuth()
navigate("/books", { replace: true })
}
const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
}`
if (!isMobile) {
return (
<div className="flex min-h-screen">
<aside className="fixed top-0 left-0 z-50 flex h-full w-56 flex-col border-r bg-background">
<div className="flex h-12 items-center gap-2 border-b px-4">
<span className="font-heading text-base font-semibold"></span>
</div>
<nav className="flex-1 space-y-1 p-3">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={linkClass} end>
<Icon className="size-4" />
{label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-2 border-t px-4 py-3">
{token ? (
<>
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{username}</span>
<Button variant="ghost" size="icon-sm" onClick={handleLogout} {...(!hideTitle && { title: "退出" })}>
<LogOutIcon className="size-4" />
</Button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground"></span>
<Button variant="ghost" size="sm" onClick={() => navigate("/login")}>
</Button>
</>
)}
</div>
</aside>
<main className="ml-56 flex-1 px-6 py-6">
<div className="mb-4 flex justify-end">
<ThemeToggle />
</div>
<Outlet />
</main>
</div>
)
}
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-[var(--safe-top,0px)] z-40 flex h-12 items-center justify-between border-b bg-background px-4">
<span className="font-heading text-sm font-semibold"></span>
<div className="flex items-center gap-2">
<ThemeToggle />
{token ? (
<>
<span className="text-sm text-muted-foreground">{username}</span>
<Button variant="ghost" size="icon-sm" onClick={handleLogout}>
<LogOutIcon className="size-4" />
</Button>
</>
) : (
<Button variant="ghost" size="sm" onClick={() => navigate("/login")}>
</Button>
)}
</div>
</header>
<main className="flex-1 px-4 py-4">
<Outlet />
</main>
<nav className="sticky bottom-[var(--safe-bottom,0px)] z-40 flex border-t bg-background">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} className={linkClass + " flex-1 justify-center py-3"} end>
<Icon className="size-4" />
<span className="text-xs">{label}</span>
</NavLink>
))}
</nav>
</div>
)
}
+2
View File
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { PlatformProvider } from "@/hooks/usePlatform" import { PlatformProvider } from "@/hooks/usePlatform"
import { MobileClass } from "@/hooks/useIsMobile" import { MobileClass } from "@/hooks/useIsMobile"
import { ThemeProvider } from "@/store/themeStore"
import { router } from "@/router" import { router } from "@/router"
import "./index.css" import "./index.css"
@@ -13,6 +14,7 @@ const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider />
<MobileClass /> <MobileClass />
<PlatformProvider> <PlatformProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
+6
View File
@@ -7,6 +7,7 @@ import MyBorrowsPage from "@/features/borrows/MyBorrowsPage"
import AdminBooksPage from "@/features/admin/AdminBooksPage" import AdminBooksPage from "@/features/admin/AdminBooksPage"
import AdminBorrowsPage from "@/features/admin/AdminBorrowsPage" import AdminBorrowsPage from "@/features/admin/AdminBorrowsPage"
import AppLayout from "@/components/layout/AppLayout" import AppLayout from "@/components/layout/AppLayout"
import UserLayout from "@/components/layout/UserLayout"
import AdminLayout from "@/components/layout/AdminLayout" import AdminLayout from "@/components/layout/AdminLayout"
function RootRedirect() { function RootRedirect() {
@@ -26,6 +27,11 @@ export const router = createBrowserRouter([
path: "/", path: "/",
element: <RootRedirect />, element: <RootRedirect />,
}, },
],
},
{
element: <UserLayout />,
children: [
{ {
path: "/books", path: "/books",
element: <BooksPage />, element: <BooksPage />,
+64
View File
@@ -0,0 +1,64 @@
import { create } from "zustand"
import { useEffect } from "react"
export type ThemeMode = "system" | "light" | "dark"
interface ThemeState {
mode: ThemeMode
setMode: (mode: ThemeMode) => void
}
function loadTheme(): ThemeMode {
try {
const v = localStorage.getItem("theme")
if (v === "light" || v === "dark" || v === "system") return v
} catch { /* unavailable */ }
return "system"
}
function systemIsDark(): boolean {
return window.matchMedia("(prefers-color-scheme: dark)").matches
}
export function effectiveTheme(mode: ThemeMode): "light" | "dark" {
if (mode === "system") return systemIsDark() ? "dark" : "light"
return mode
}
function applyTheme(mode: ThemeMode) {
const root = document.documentElement
if (effectiveTheme(mode) === "dark") {
root.classList.add("dark")
} else {
root.classList.remove("dark")
}
}
export const useThemeStore = create<ThemeState>((set) => ({
mode: loadTheme(),
setMode: (mode) => {
localStorage.setItem("theme", mode)
applyTheme(mode)
set({ mode })
},
}))
export function ThemeProvider() {
const mode = useThemeStore((s) => s.mode)
useEffect(() => {
applyTheme(mode)
}, [mode])
useEffect(() => {
if (mode !== "system") return
const mql = window.matchMedia("(prefers-color-scheme: dark)")
function sync() {
applyTheme("system")
}
mql.addEventListener("change", sync)
return () => mql.removeEventListener("change", sync)
}, [mode])
return null
}