mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-18 11:29:22 +08:00
perf: 优化亮暗模式切换方式
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Table2,
|
||||
Moon,
|
||||
Sun,
|
||||
SunMoon,
|
||||
CircleUserIcon,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
@@ -18,23 +19,19 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface HeaderProps {
|
||||
viewMode: "grid" | "table";
|
||||
setViewMode: (mode: "grid" | "table") => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
}
|
||||
|
||||
export const Header = ({
|
||||
viewMode,
|
||||
setViewMode,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
}: HeaderProps) => {
|
||||
const { appearance, setAppearance } = useTheme();
|
||||
export const Header = ({ searchTerm, setSearchTerm }: HeaderProps) => {
|
||||
const { rawAppearance, setAppearance, viewMode, setViewMode } = useTheme();
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const isInstancePage = location.pathname.startsWith("/instance");
|
||||
@@ -52,10 +49,6 @@ export const Header = ({
|
||||
}
|
||||
}, [sitename]);
|
||||
|
||||
const toggleAppearance = () => {
|
||||
setAppearance(appearance === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="purcarte-blur border-b border-(--accent-a4) shadow-sm shadow-(color:--accent-a4) sticky top-0 flex items-center justify-center z-10">
|
||||
<div className="w-[90%] max-w-screen-2xl px-4 py-2 flex items-center justify-between">
|
||||
@@ -128,16 +121,35 @@ export const Header = ({
|
||||
{viewMode === "grid" ? "表格视图" : "网格视图"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={toggleAppearance}>
|
||||
{appearance === "dark" ? (
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
)}
|
||||
<span>
|
||||
{appearance === "dark" ? "浅色模式" : "深色模式"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{rawAppearance === "light" ? (
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
) : rawAppearance === "dark" ? (
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<SunMoon className="size-4 mr-2 text-primary" />
|
||||
)}
|
||||
<span>切换主题</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="purcarte-blur border-(--accent-4)/50 rounded-xl">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setAppearance("light")}>
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
<span>浅色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setAppearance("dark")}>
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
<span>深色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setAppearance("system")}>
|
||||
<SunMoon className="size-4 mr-2 text-primary" />
|
||||
<span>跟随系统</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{enableAdminButton && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
@@ -195,16 +207,35 @@ export const Header = ({
|
||||
<Grid3X3 className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleAppearance}>
|
||||
{appearance === "dark" ? (
|
||||
<Sun className="size-5 text-primary" />
|
||||
) : (
|
||||
<Moon className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
{rawAppearance === "light" ? (
|
||||
<Sun className="size-5 text-primary" />
|
||||
) : rawAppearance === "dark" ? (
|
||||
<Moon className="size-5 text-primary" />
|
||||
) : (
|
||||
<SunMoon className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="purcarte-blur mt-[.5rem] border-(--accent-4)/50 rounded-xl">
|
||||
<DropdownMenuItem onClick={() => setAppearance("light")}>
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
<span>浅色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAppearance("dark")}>
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
<span>深色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAppearance("system")}>
|
||||
<SunMoon className="size-4 mr-2 text-primary" />
|
||||
<span>跟随系统</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{enableAdminButton && (
|
||||
<a href="/admin" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="icon">
|
||||
@@ -232,16 +263,34 @@ export const Header = ({
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="animate-in slide-in-from-top-5 duration-300 purcarte-blur border-(--accent-4)/50 rounded-xl">
|
||||
<DropdownMenuItem onClick={toggleAppearance}>
|
||||
{appearance === "dark" ? (
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
)}
|
||||
<span>
|
||||
{appearance === "dark" ? "浅色模式" : "深色模式"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{rawAppearance === "light" ? (
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
) : rawAppearance === "dark" ? (
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
) : (
|
||||
<SunMoon className="size-4 mr-2 text-primary" />
|
||||
)}
|
||||
<span>切换主题</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="purcarte-blur border-(--accent-4)/50 rounded-xl">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setAppearance("light")}>
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
<span>浅色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAppearance("dark")}>
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
<span>深色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setAppearance("system")}>
|
||||
<SunMoon className="size-4 mr-2 text-primary" />
|
||||
<span>跟随系统</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{enableAdminButton && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
@@ -258,16 +307,35 @@ export const Header = ({
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleAppearance}>
|
||||
{appearance === "dark" ? (
|
||||
<Sun className="size-5 text-primary" />
|
||||
) : (
|
||||
<Moon className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
{rawAppearance === "light" ? (
|
||||
<Sun className="size-5 text-primary" />
|
||||
) : rawAppearance === "dark" ? (
|
||||
<Moon className="size-5 text-primary" />
|
||||
) : (
|
||||
<SunMoon className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="purcarte-blur mt-[.5rem] border-(--accent-4)/50 rounded-xl">
|
||||
<DropdownMenuItem onClick={() => setAppearance("light")}>
|
||||
<Sun className="size-4 mr-2 text-primary" />
|
||||
<span>浅色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAppearance("dark")}>
|
||||
<Moon className="size-4 mr-2 text-primary" />
|
||||
<span>深色模式</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAppearance("system")}>
|
||||
<SunMoon className="size-4 mr-2 text-primary" />
|
||||
<span>跟随系统</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{enableAdminButton && (
|
||||
<a href="/admin" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="icon">
|
||||
|
@@ -42,14 +42,18 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] purcarte-blur overflow-hidden theme-card-style bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<Theme>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] purcarte-blur overflow-hidden theme-card-style bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</Theme>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, createContext } from "react";
|
||||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { useConfigItem } from "@/config";
|
||||
import { DEFAULT_CONFIG } from "@/config/default";
|
||||
|
||||
export const allowedColors = [
|
||||
"gray",
|
||||
@@ -35,25 +36,24 @@ export type Colors = (typeof allowedColors)[number];
|
||||
export const allowedAppearances = ["light", "dark", "system"] as const;
|
||||
export type Appearance = (typeof allowedAppearances)[number];
|
||||
|
||||
export const THEME_DEFAULTS = {
|
||||
appearance: "system" as Appearance,
|
||||
color: "gray" as Colors,
|
||||
} as const;
|
||||
|
||||
export interface ThemeContextType {
|
||||
appearance: "light" | "dark";
|
||||
rawAppearance: Appearance;
|
||||
setAppearance: (appearance: Appearance) => void;
|
||||
color: Colors;
|
||||
setColor: (color: Colors) => void;
|
||||
viewMode: "grid" | "table";
|
||||
setViewMode: (mode: "grid" | "table") => void;
|
||||
}
|
||||
|
||||
export const ThemeContext = createContext<ThemeContextType>({
|
||||
appearance: "light",
|
||||
rawAppearance: THEME_DEFAULTS.appearance,
|
||||
rawAppearance: DEFAULT_CONFIG.selectedDefaultAppearance as Appearance,
|
||||
setAppearance: () => {},
|
||||
color: THEME_DEFAULTS.color,
|
||||
color: DEFAULT_CONFIG.selectThemeColor as Colors,
|
||||
setColor: () => {},
|
||||
viewMode: DEFAULT_CONFIG.selectedDefaultView as "grid" | "table",
|
||||
setViewMode: () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -98,48 +98,69 @@ export const useSystemTheme = (appearance: Appearance): "light" | "dark" => {
|
||||
return appearance as "light" | "dark";
|
||||
};
|
||||
|
||||
import { useContext } from "react";
|
||||
|
||||
export const useThemeManager = () => {
|
||||
const useStoredState = <T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
validator?: (value: any) => value is T
|
||||
): [T, React.Dispatch<React.SetStateAction<T>>] => {
|
||||
const enableLocalStorage = useConfigItem("enableLocalStorage");
|
||||
const defaultAppearance = useConfigItem("selectedDefaultAppearance");
|
||||
const defaultColor = useConfigItem("selectThemeColor");
|
||||
|
||||
const [appearance, setAppearance] = useState<Appearance>(() => {
|
||||
const [state, setState] = useState<T>(() => {
|
||||
if (enableLocalStorage) {
|
||||
const storedAppearance = localStorage.getItem("appearance");
|
||||
const cleanedAppearance = storedAppearance
|
||||
? storedAppearance.replace(/^"|"$/g, "")
|
||||
: null;
|
||||
if (allowedAppearances.includes(cleanedAppearance as Appearance)) {
|
||||
return cleanedAppearance as Appearance;
|
||||
const storedValue = localStorage.getItem(key);
|
||||
if (storedValue) {
|
||||
const cleanedValue = storedValue.replace(/^"|"$/g, "");
|
||||
if (!validator || validator(cleanedValue)) {
|
||||
return cleanedValue as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (defaultAppearance as Appearance) || THEME_DEFAULTS.appearance;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const [color, setColor] = useState<Colors>(
|
||||
(defaultColor as Colors) || THEME_DEFAULTS.color
|
||||
useEffect(() => {
|
||||
if (enableLocalStorage) {
|
||||
localStorage.setItem(key, String(state));
|
||||
}
|
||||
}, [key, state, enableLocalStorage]);
|
||||
|
||||
return [state, setState];
|
||||
};
|
||||
|
||||
export const useThemeManager = () => {
|
||||
const defaultAppearance = useConfigItem(
|
||||
"selectedDefaultAppearance"
|
||||
) as Appearance;
|
||||
const defaultColor = useConfigItem("selectThemeColor") as Colors;
|
||||
const defaultView = useConfigItem("selectedDefaultView") as "grid" | "table";
|
||||
|
||||
const [appearance, setAppearance] = useStoredState<Appearance>(
|
||||
"appearance",
|
||||
defaultAppearance,
|
||||
(v): v is Appearance => allowedAppearances.includes(v)
|
||||
);
|
||||
|
||||
const [color, setColor] = useStoredState<Colors>("color", defaultColor);
|
||||
|
||||
const [viewMode, setViewMode] = useStoredState<"grid" | "table">(
|
||||
"nodeViewMode",
|
||||
defaultView
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColor((defaultColor as Colors) || THEME_DEFAULTS.color);
|
||||
}, [defaultColor]);
|
||||
setColor(defaultColor);
|
||||
}, [defaultColor, setColor]);
|
||||
|
||||
const resolvedAppearance = useSystemTheme(appearance);
|
||||
|
||||
useEffect(() => {
|
||||
if (enableLocalStorage) {
|
||||
localStorage.setItem("appearance", appearance);
|
||||
}
|
||||
}, [appearance, enableLocalStorage]);
|
||||
|
||||
return {
|
||||
appearance: resolvedAppearance,
|
||||
rawAppearance: appearance,
|
||||
setAppearance,
|
||||
color,
|
||||
setColor,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
};
|
||||
};
|
||||
export const useTheme = () => {
|
||||
|
29
src/main.tsx
29
src/main.tsx
@@ -1,4 +1,4 @@
|
||||
import { StrictMode, useState, useEffect, lazy, Suspense } from "react";
|
||||
import { StrictMode, useState, lazy, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||
import "./index.css";
|
||||
@@ -23,29 +23,10 @@ import { useConfigItem } from "@/config";
|
||||
// 内部应用组件,在 ConfigProvider 内部使用配置
|
||||
export const AppContent = () => {
|
||||
const { appearance, color } = useTheme();
|
||||
const defaultView = useConfigItem("selectedDefaultView");
|
||||
const enableLocalStorage = useConfigItem("enableLocalStorage");
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
||||
if (enableLocalStorage) {
|
||||
const savedMode = localStorage.getItem("nodeViewMode");
|
||||
const cleanedMode = savedMode ? savedMode.replace(/^"|"$/g, "") : null;
|
||||
if (cleanedMode === "grid" || cleanedMode === "table") {
|
||||
return cleanedMode;
|
||||
}
|
||||
}
|
||||
return defaultView || "grid";
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const enableVideoBackground = useConfigItem("enableVideoBackground");
|
||||
const videoBackgroundUrl = useConfigItem("videoBackgroundUrl");
|
||||
|
||||
useEffect(() => {
|
||||
if (enableLocalStorage) {
|
||||
localStorage.setItem("nodeViewMode", viewMode);
|
||||
}
|
||||
}, [enableLocalStorage, viewMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{enableVideoBackground && videoBackgroundUrl && (
|
||||
@@ -63,19 +44,13 @@ export const AppContent = () => {
|
||||
scaling="110%"
|
||||
style={{ backgroundColor: "transparent" }}>
|
||||
<div className="min-h-screen flex flex-col text-sm">
|
||||
<Header
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
/>
|
||||
<Header searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<HomePage
|
||||
viewMode={viewMode}
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
/>
|
||||
|
@@ -9,6 +9,7 @@ import type { NodeWithStatus } from "@/types/node";
|
||||
import { useNodeData } from "@/contexts/NodeDataContext";
|
||||
import { useLiveData } from "@/contexts/LiveDataContext";
|
||||
import { useAppConfig } from "@/config";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface HomePageProps {
|
||||
viewMode: "grid" | "table";
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
}
|
||||
@@ -28,11 +28,8 @@ const homeStateCache = {
|
||||
scrollPosition: 0,
|
||||
};
|
||||
|
||||
const HomePage: React.FC<HomePageProps> = ({
|
||||
viewMode,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
}) => {
|
||||
const HomePage: React.FC<HomePageProps> = ({ searchTerm, setSearchTerm }) => {
|
||||
const { viewMode } = useTheme();
|
||||
const { nodes: staticNodes, loading, getGroups } = useNodeData();
|
||||
const { liveData } = useLiveData();
|
||||
const [selectedGroup, setSelectedGroup] = useState(
|
||||
|
Reference in New Issue
Block a user