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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom"
|
||||
import { BookOpenIcon, ListIcon, 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"
|
||||
@@ -45,6 +46,7 @@ export default function AdminLayout() {
|
||||
</nav>
|
||||
<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>
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleLogout} {...(!hideTitle && { title: "退出" })}>
|
||||
<LogOutIcon className="size-4" />
|
||||
</Button>
|
||||
@@ -63,6 +65,7 @@ export default function AdminLayout() {
|
||||
<span className="font-heading text-sm font-semibold">管理后台</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{username}</span>
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleLogout}>
|
||||
<LogOutIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, Outlet, useNavigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ThemeToggle from "@/components/ThemeToggle"
|
||||
|
||||
export default function AppLayout() {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
@@ -31,6 +32,7 @@ export default function AppLayout() {
|
||||
)}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
{token ? (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">{username}</span>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { PlatformProvider } from "@/hooks/usePlatform"
|
||||
import { MobileClass } from "@/hooks/useIsMobile"
|
||||
import { ThemeProvider } from "@/store/themeStore"
|
||||
import { router } from "@/router"
|
||||
import "./index.css"
|
||||
|
||||
@@ -13,6 +14,7 @@ const queryClient = new QueryClient()
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider />
|
||||
<MobileClass />
|
||||
<PlatformProvider>
|
||||
<RouterProvider router={router} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import MyBorrowsPage from "@/features/borrows/MyBorrowsPage"
|
||||
import AdminBooksPage from "@/features/admin/AdminBooksPage"
|
||||
import AdminBorrowsPage from "@/features/admin/AdminBorrowsPage"
|
||||
import AppLayout from "@/components/layout/AppLayout"
|
||||
import UserLayout from "@/components/layout/UserLayout"
|
||||
import AdminLayout from "@/components/layout/AdminLayout"
|
||||
|
||||
function RootRedirect() {
|
||||
@@ -26,6 +27,11 @@ export const router = createBrowserRouter([
|
||||
path: "/",
|
||||
element: <RootRedirect />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <UserLayout />,
|
||||
children: [
|
||||
{
|
||||
path: "/books",
|
||||
element: <BooksPage />,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user