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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 { 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} />
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
|||||||
@@ -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