mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-19 20:09:24 +08:00
init: 初始化
This commit is contained in:
77
src/components/Loading.css
Normal file
77
src/components/Loading.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.loader {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
.loader:before {
|
||||
content: "";
|
||||
display: block;
|
||||
padding-top: 100%;
|
||||
}
|
||||
|
||||
.circular {
|
||||
-webkit-animation: rotate 2s linear infinite;
|
||||
animation: rotate 2s linear infinite;
|
||||
height: 100%;
|
||||
-webkit-transform-origin: center center;
|
||||
transform-origin: center center;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.path {
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
-webkit-animation: dash 1.5s ease-in-out infinite;
|
||||
animation: dash 1.5s ease-in-out infinite;
|
||||
stroke-linecap: round;
|
||||
stroke: #3b82f6;
|
||||
}
|
||||
|
||||
@-webkit-keyframes rotate {
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -35px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -124px;
|
||||
}
|
||||
}
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 200;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -35px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 89, 200;
|
||||
stroke-dashoffset: -124px;
|
||||
}
|
||||
}
|
34
src/components/loading.tsx
Normal file
34
src/components/loading.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import "./Loading.css";
|
||||
|
||||
type LoadingProps = {
|
||||
text?: string;
|
||||
children?: React.ReactNode;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const Loading = ({ text, children, size }: LoadingProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center flex-col">
|
||||
<div className={`showbox scale-${size ? size * 10 : 50}`}>
|
||||
<div className="loader">
|
||||
<svg className="circular" viewBox="25 25 50 50">
|
||||
<circle
|
||||
className="path"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="20"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeMiterlimit="10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-bold">Loading...</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">{text}</p>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
104
src/components/sections/Flag.tsx
Normal file
104
src/components/sections/Flag.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as React from "react";
|
||||
import { Box } from "@radix-ui/themes";
|
||||
|
||||
interface FlagProps {
|
||||
flag: string; // 地区代码 (例如 "SG", "US") 或旗帜 emoji (例如 "🇸🇬", "🇺🇳")
|
||||
size?: string; // 可选的尺寸 prop,用于未来扩展
|
||||
}
|
||||
|
||||
/**
|
||||
* 算法:将由两个区域指示符符号组成的 emoji 转换为对应的两字母国家代码。
|
||||
* 例如:🇸🇬 (由两个区域指示符组成) -> SG
|
||||
* @param emoji 输入的 emoji 字符串
|
||||
* @returns 转换后的两字母国家代码(例如 "SG"),如果不是有效的旗帜 emoji 则返回 null。
|
||||
*/
|
||||
const getCountryCodeFromFlagEmoji = (emoji: string): string | null => {
|
||||
// 使用 Array.from() 来正确处理 Unicode 代理对,将 emoji 字符串拆分为逻辑上的字符数组。
|
||||
// 对于一个国家旗帜 emoji,chars 数组的长度将是 2 (每个元素是一个区域指示符字符)。
|
||||
const chars = Array.from(emoji);
|
||||
|
||||
// 国家旗帜 emoji 应该由且仅由两个区域指示符字符组成
|
||||
if (chars.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取两个区域指示符字符的 Unicode 码点
|
||||
const codePoint1 = chars[0].codePointAt(0)!;
|
||||
const codePoint2 = chars[1].codePointAt(0)!;
|
||||
|
||||
// 区域指示符符号的 Unicode 范围是从 U+1F1E6 (🇦) 到 U+1F1FF (🇿)
|
||||
const REGIONAL_INDICATOR_START = 0x1f1e6; // 🇦 的 Unicode 码点
|
||||
const ASCII_ALPHA_START = 0x41; // A 的 ASCII 码点
|
||||
|
||||
// 检查两个码点是否都在区域指示符范围内
|
||||
if (
|
||||
codePoint1 >= REGIONAL_INDICATOR_START &&
|
||||
codePoint1 <= 0x1f1ff &&
|
||||
codePoint2 >= REGIONAL_INDICATOR_START &&
|
||||
codePoint2 <= 0x1f1ff
|
||||
) {
|
||||
// 算法转换:通过计算与 'A' 对应的区域指示符的偏移量,将区域指示符码点转换回对应的 ASCII 字母码点
|
||||
const letter1 = String.fromCodePoint(
|
||||
codePoint1 - REGIONAL_INDICATOR_START + ASCII_ALPHA_START
|
||||
);
|
||||
const letter2 = String.fromCodePoint(
|
||||
codePoint2 - REGIONAL_INDICATOR_START + ASCII_ALPHA_START
|
||||
);
|
||||
return `${letter1}${letter2}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const Flag = React.memo(({ flag, size }: FlagProps) => {
|
||||
let imgSrc: string;
|
||||
let altText: string;
|
||||
let resolvedFlagFileName: string; // 最终用于构建文件名的字符串 (例如 "SG", "UN")
|
||||
|
||||
// 1. **算法处理:** 尝试将输入作为由区域指示符组成的旗帜 emoji 进行转换
|
||||
const countryCodeFromEmoji = getCountryCodeFromFlagEmoji(flag);
|
||||
|
||||
if (countryCodeFromEmoji) {
|
||||
resolvedFlagFileName = countryCodeFromEmoji; // 例如,如果输入是 "🇸🇬",则这里得到 "SG"
|
||||
}
|
||||
// 2. **直接识别:** 如果不是区域指示符 emoji,检查是否是两字母的字母组合(ISO 国家代码)
|
||||
else if (flag && flag.length === 2 && /^[a-zA-Z]{2}$/.test(flag)) {
|
||||
resolvedFlagFileName = flag.toUpperCase(); // 例如,如果输入是 "us",则这里得到 "US"
|
||||
}
|
||||
// 3. **硬编码处理特殊 Emoji:** 对于无法通过算法转换的特殊 emoji(例如 🇺🇳, 🌐),
|
||||
// 因为它们不符合区域指示符模式,且不使用映射表,只能通过硬编码来识别。
|
||||
else if (flag === "🇺🇳" || flag === "🌐") {
|
||||
resolvedFlagFileName = "UN"; // 例如,如果输入是 "🇺🇳",则这里得到 "UN"
|
||||
}
|
||||
// 4. **回退:** 对于任何其他无法识别的输入(包括不符合上述规则的 emoji 或非两字母代码),
|
||||
// 使用默认的 "UN" 旗帜作为回退。
|
||||
else {
|
||||
resolvedFlagFileName = "UN";
|
||||
}
|
||||
|
||||
// 构建本地图片路径
|
||||
imgSrc = `/assets/flags/${resolvedFlagFileName}.svg`;
|
||||
// 构建 alt 文本和 aria-label
|
||||
altText = `地区旗帜: ${resolvedFlagFileName}`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
className={`self-center flex-shrink-0 ${
|
||||
size ? `w-${size} h-${size}` : "w-6 h-6"
|
||||
}`}
|
||||
style={{ display: "inline-flex", alignItems: "center" }}
|
||||
aria-label={altText}>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={altText}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
// 确保 displayName 以便在 React DevTools 中识别
|
||||
Flag.displayName = "Flag";
|
||||
|
||||
export default Flag;
|
29
src/components/sections/Footer.tsx
Normal file
29
src/components/sections/Footer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="fixed shadow-inner bottom-0 left-0 right-0 p-2 text-center bg-white/10 dark:bg-gray-800/10 backdrop-blur-md border-t border-white/20 dark:border-white/10 z-50">
|
||||
<p className="flex justify-center text-sm text-gray-700 dark:text-gray-200 text-shadow-lg whitespace-pre">
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://github.com/komari-monitor/komari"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 transition-colors">
|
||||
Komari Monitor
|
||||
</a>
|
||||
{" | "}
|
||||
Theme by{" "}
|
||||
<a
|
||||
href=""
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 transition-colors">
|
||||
PurCarte
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
120
src/components/sections/Header.tsx
Normal file
120
src/components/sections/Header.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Search,
|
||||
Grid3X3,
|
||||
Table2,
|
||||
Moon,
|
||||
Sun,
|
||||
CircleUserIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
|
||||
interface HeaderProps {
|
||||
viewMode: "card" | "list";
|
||||
setViewMode: (mode: "card" | "list") => void;
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
sitename: string;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
}
|
||||
|
||||
export const Header = ({
|
||||
viewMode,
|
||||
setViewMode,
|
||||
theme,
|
||||
toggleTheme,
|
||||
sitename,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
}: HeaderProps) => {
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const isInstancePage = location.pathname.startsWith("/instance");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<header className="bg-background/60 backdrop-blur-[10px] border-b border-border/60 sticky top-0 flex items-center justify-center shadow-sm z-10">
|
||||
<div className="w-[90%] max-w-screen-2xl px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center text-shadow-lg text-accent-foreground">
|
||||
<a href="/" className="text-2xl font-bold">
|
||||
{sitename}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{!isInstancePage && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{isSearchOpen && (
|
||||
<div className="absolute top-full left-0 w-full bg-background/80 backdrop-blur-md p-2 border-b border-border/60 shadow-sm z-10 transition-all duration-300 ease-in-out">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索服务器..."
|
||||
className="w-full"
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearchTerm(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-center transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isSearchOpen ? "w-48" : "w-0"
|
||||
}`}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索服务器..."
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
isSearchOpen ? "opacity-100" : "opacity-0"
|
||||
} ${!isSearchOpen && "invisible"}`}
|
||||
value={searchTerm}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSearchTerm(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}>
|
||||
<Search className="size-5 text-primary" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setViewMode(viewMode === "card" ? "list" : "card")
|
||||
}>
|
||||
{viewMode === "card" ? (
|
||||
<Table2 className="size-5 text-primary" />
|
||||
) : (
|
||||
<Grid3X3 className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme}>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="size-5 text-primary" />
|
||||
) : (
|
||||
<Moon className="size-5 text-primary" />
|
||||
)}
|
||||
</Button>
|
||||
<a href="/admin" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="icon">
|
||||
<CircleUserIcon className="size-5 text-primary" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
188
src/components/sections/NodeCard.tsx
Normal file
188
src/components/sections/NodeCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { formatBytes, formatUptime, getOSImage } from "@/utils";
|
||||
import type { NodeWithStatus } from "@/types/node";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react";
|
||||
import Flag from "./Flag";
|
||||
import { Tag } from "../ui/tag";
|
||||
import { useNodeCommons } from "@/hooks/useNodeCommons";
|
||||
|
||||
interface NodeCardProps {
|
||||
node: NodeWithStatus;
|
||||
}
|
||||
|
||||
const ProgressBar = ({
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
value: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700">
|
||||
<div
|
||||
className={`h-3 rounded-full ${className}`}
|
||||
style={{ width: `${value}%` }}></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const NodeCard = ({ node }: NodeCardProps) => {
|
||||
const {
|
||||
stats,
|
||||
isOnline,
|
||||
tagList,
|
||||
cpuUsage,
|
||||
memUsage,
|
||||
swapUsage,
|
||||
diskUsage,
|
||||
load,
|
||||
daysLeft,
|
||||
} = useNodeCommons(node);
|
||||
|
||||
const getProgressBarClass = (percentage: number) => {
|
||||
if (percentage > 90) return "bg-red-600";
|
||||
if (percentage > 50) return "bg-yellow-400";
|
||||
return "bg-green-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col mx-auto bg-card backdrop-blur-xs w-full min-w-[280px] max-w-sm ${
|
||||
isOnline
|
||||
? ""
|
||||
: "striped-bg-red-translucent-diagonal ring-2 ring-red-500/50"
|
||||
}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flag flag={node.region}></Flag>
|
||||
<img
|
||||
src={getOSImage(node.os)}
|
||||
alt={node.os}
|
||||
className="w-6 h-6 rounded-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
<CardTitle className="text-base font-bold">
|
||||
<Link to={`/instance/${node.uuid}`}>{node.name}</Link>
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Tag tags={tagList} />
|
||||
</div>
|
||||
<div className="border-t border-border/60 my-2"></div>
|
||||
<div className="flex items-center justify-around whitespace-nowrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<CpuIcon className="size-4 text-blue-600 flex-shrink-0" />
|
||||
<span className="text-secondary-foreground">
|
||||
{node.cpu_cores} Cores
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStickIcon className="size-4 text-green-600 flex-shrink-0" />
|
||||
<span className="text-secondary-foreground">
|
||||
{formatBytes(node.mem_total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDriveIcon className="size-4 text-red-600 flex-shrink-0" />
|
||||
<span className="text-secondary-foreground">
|
||||
{formatBytes(node.disk_total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">CPU</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={cpuUsage}
|
||||
className={getProgressBarClass(cpuUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{cpuUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">内存</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={memUsage}
|
||||
className={getProgressBarClass(memUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{memUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{node.swap_total > 0 ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">SWAP</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={swapUsage}
|
||||
className={getProgressBarClass(swapUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{swapUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">SWAP</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar value={0} />
|
||||
<span className="w-12 text-right">OFF</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-secondary-foreground">硬盘</span>
|
||||
<div className="w-3/4 flex items-center gap-2">
|
||||
<ProgressBar
|
||||
value={diskUsage}
|
||||
className={getProgressBarClass(diskUsage)}
|
||||
/>
|
||||
<span className="w-12 text-right">{diskUsage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/60 my-2"></div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-secondary-foreground">网络</span>
|
||||
<div>
|
||||
<span>↑ {stats ? formatBytes(stats.network.up, true) : "N/A"}</span>
|
||||
<span className="ml-2">
|
||||
↓ {stats ? formatBytes(stats.network.down, true) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-secondary-foreground">流量</span>
|
||||
<div>
|
||||
<span>↑ {stats ? formatBytes(stats.network.totalUp) : "N/A"}</span>
|
||||
<span className="ml-2">
|
||||
↓ {stats ? formatBytes(stats.network.totalDown) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-secondary-foreground">负载</span>
|
||||
<span>{load}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="text-secondary-foreground">到期</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{daysLeft !== null && daysLeft > 36500
|
||||
? "长期"
|
||||
: node.expired_at
|
||||
? new Date(node.expired_at).toLocaleDateString()
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l border-border/60 mx-2"></div>
|
||||
<div className="flex justify-between w-full">
|
||||
<span className="text-secondary-foreground">在线</span>
|
||||
<span>
|
||||
{isOnline && stats ? formatUptime(stats.uptime) : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
14
src/components/sections/NodeListHeader.tsx
Normal file
14
src/components/sections/NodeListHeader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export const NodeListHeader = () => {
|
||||
return (
|
||||
<div className="text-primary font-bold grid grid-cols-12 text-center shadow-md gap-4 p-2 items-center rounded-lg bg-card/50 transition-colors duration-200">
|
||||
<div className="col-span-3">节点名称</div>
|
||||
<div className="col-span-1">CPU</div>
|
||||
<div className="col-span-1">内存</div>
|
||||
<div className="col-span-1">SWAP</div>
|
||||
<div className="col-span-1">硬盘</div>
|
||||
<div className="col-span-2">网络</div>
|
||||
<div className="col-span-2">流量</div>
|
||||
<div className="col-span-1">负载</div>
|
||||
</div>
|
||||
);
|
||||
};
|
112
src/components/sections/NodeListItem.tsx
Normal file
112
src/components/sections/NodeListItem.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { formatBytes, formatUptime } from "@/utils";
|
||||
import type { NodeWithStatus } from "@/types/node";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from "lucide-react";
|
||||
import Flag from "./Flag";
|
||||
import { Tag } from "../ui/tag";
|
||||
import { useNodeCommons } from "@/hooks/useNodeCommons";
|
||||
|
||||
interface NodeListItemProps {
|
||||
node: NodeWithStatus;
|
||||
}
|
||||
|
||||
export const NodeListItem = ({ node }: NodeListItemProps) => {
|
||||
const {
|
||||
stats,
|
||||
isOnline,
|
||||
tagList,
|
||||
cpuUsage,
|
||||
memUsage,
|
||||
swapUsage,
|
||||
diskUsage,
|
||||
load,
|
||||
daysLeft,
|
||||
} = useNodeCommons(node);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-12 text-center shadow-md gap-4 p-2 items-center rounded-lg ${
|
||||
isOnline
|
||||
? ""
|
||||
: "striped-bg-red-translucent-diagonal ring-2 ring-red-500/50"
|
||||
} text-secondary-foreground transition-colors duration-200`}>
|
||||
<div className="col-span-3 flex items-center text-left">
|
||||
<Flag flag={node.region} />
|
||||
<div className="ml-2 w-full">
|
||||
<div className="text-base font-bold">
|
||||
<Link to={`/instance/${node.uuid}`}>{node.name}</Link>
|
||||
</div>
|
||||
<Tag className="text-xs" tags={tagList} />
|
||||
<div className="flex text-xs">
|
||||
<div className="flex">
|
||||
<span className="text-secondary-foreground">到期:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{daysLeft !== null && daysLeft > 36500
|
||||
? "长期"
|
||||
: node.expired_at
|
||||
? new Date(node.expired_at).toLocaleDateString()
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l border-border/60 mx-2"></div>
|
||||
<div className="flex">
|
||||
<span className="text-secondary-foreground">在线:</span>
|
||||
<span>
|
||||
{isOnline && stats ? formatUptime(stats.uptime) : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="gap-1 flex items-center justify-center whitespace-nowrap">
|
||||
<CpuIcon className="inline-block size-4 flex-shrink-0 text-blue-600" />
|
||||
{node.cpu_cores} Cores
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{isOnline ? `${cpuUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<div className="gap-1 flex items-center justify-center whitespace-nowrap">
|
||||
<MemoryStickIcon className="inline-block size-4 flex-shrink-0 text-green-600" />
|
||||
{formatBytes(node.mem_total)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{isOnline ? `${memUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
{node.swap_total > 0 ? (
|
||||
<div className="col-span-1">
|
||||
{isOnline ? `${swapUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-1 text-secondary-foreground">OFF</div>
|
||||
)}
|
||||
<div className="col-span-1">
|
||||
<div className="gap-1 flex items-center justify-center whitespace-nowrap">
|
||||
<HardDriveIcon className="inline-block size-4 flex-shrink-0 text-red-600" />
|
||||
{formatBytes(node.disk_total)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{isOnline ? `${diskUsage.toFixed(1)}%` : "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span>
|
||||
↑ {stats ? formatBytes(stats.network.up, true) : "N/A"}↓{" "}
|
||||
{stats ? formatBytes(stats.network.down, true) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span>
|
||||
↑ {stats ? formatBytes(stats.network.totalUp) : "N/A"}↓{" "}
|
||||
{stats ? formatBytes(stats.network.totalDown) : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<span>{load}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
186
src/components/sections/StatsBar.tsx
Normal file
186
src/components/sections/StatsBar.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatBytes } from "@/utils";
|
||||
|
||||
interface StatsBarProps {
|
||||
displayOptions: {
|
||||
time: boolean;
|
||||
online: boolean;
|
||||
regions: boolean;
|
||||
traffic: boolean;
|
||||
speed: boolean;
|
||||
};
|
||||
setDisplayOptions: (options: any) => void;
|
||||
stats: {
|
||||
onlineCount: number;
|
||||
totalCount: number;
|
||||
uniqueRegions: number;
|
||||
totalTrafficUp: number;
|
||||
totalTrafficDown: number;
|
||||
currentSpeedUp: number;
|
||||
currentSpeedDown: number;
|
||||
};
|
||||
loading: boolean;
|
||||
currentTime: Date;
|
||||
}
|
||||
|
||||
export const StatsBar = ({
|
||||
displayOptions,
|
||||
setDisplayOptions,
|
||||
stats,
|
||||
loading,
|
||||
currentTime,
|
||||
}: StatsBarProps) => {
|
||||
return (
|
||||
<div className="bg-card backdrop-blur-[10px] min-w-[300px] rounded-lg box-border border text-secondary-foreground m-4 px-4 md:text-base text-sm relative flex items-center min-h-[5rem]">
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="cursor-pointer">
|
||||
<Settings2 />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>状态显示设置</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>当前时间</span>
|
||||
<Switch
|
||||
checked={displayOptions.time}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, time: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>当前在线</span>
|
||||
<Switch
|
||||
checked={displayOptions.online}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, online: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>点亮地区</span>
|
||||
<Switch
|
||||
checked={displayOptions.regions}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, regions: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>流量概览</span>
|
||||
<Switch
|
||||
checked={displayOptions.traffic}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, traffic: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="flex items-center justify-between cursor-pointer">
|
||||
<span>网络速率</span>
|
||||
<Switch
|
||||
checked={displayOptions.speed}
|
||||
onCheckedChange={(checked) =>
|
||||
setDisplayOptions({ ...displayOptions, speed: checked })
|
||||
}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div
|
||||
className="grid w-full gap-2 text-center items-center"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gridAutoRows: "min-content",
|
||||
}}>
|
||||
{displayOptions.time && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
当前时间
|
||||
</label>
|
||||
<label className="font-medium -mt-2 text-md">
|
||||
{currentTime.toLocaleTimeString()}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.online && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
当前在线
|
||||
</label>
|
||||
<label className="font-medium -mt-2 text-md">
|
||||
{loading ? "..." : `${stats.onlineCount} / ${stats.totalCount}`}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.regions && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
点亮地区
|
||||
</label>
|
||||
<label className="font-medium -mt-2 text-md">
|
||||
{loading ? "..." : stats.uniqueRegions}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.traffic && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
流量概览
|
||||
</label>
|
||||
<div className="font-medium -mt-2 text-md">
|
||||
{loading ? (
|
||||
"..."
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{`↑ ${formatBytes(stats.totalTrafficUp)}`}</span>
|
||||
<span>{`↓ ${formatBytes(stats.totalTrafficDown)}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{displayOptions.speed && (
|
||||
<div className="w-full">
|
||||
<div className="rt-Flex rt-r-fd-column rt-r-gap-2">
|
||||
<label className="text-secondary-foreground text-sm">
|
||||
网络速率
|
||||
</label>
|
||||
<div className="font-medium -mt-2 text-md">
|
||||
{loading ? (
|
||||
"..."
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{`↑ ${formatBytes(stats.currentSpeedUp)}/s`}</span>
|
||||
<span>{`↓ ${formatBytes(stats.currentSpeedDown)}/s`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export { Button, buttonVariants };
|
83
src/components/ui/card.tsx
Normal file
83
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card backdrop-blur-xs text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-secondary-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
351
src/components/ui/chart.tsx
Normal file
351
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}>
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{formatter
|
||||
? formatter(
|
||||
item.value,
|
||||
item.name ?? "",
|
||||
item,
|
||||
index,
|
||||
item.payload
|
||||
)
|
||||
: item.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
193
src/components/ui/dropdown-menu.tsx
Normal file
193
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { DotFilledIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
import { Check, ChevronRight } from "lucide-react";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
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] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
};
|
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-secondary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
25
src/components/ui/progress.tsx
Normal file
25
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
28
src/components/ui/switch.tsx
Normal file
28
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
107
src/components/ui/tag.tsx
Normal file
107
src/components/ui/tag.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Badge } from "@radix-ui/themes";
|
||||
import React from "react";
|
||||
import { cn } from "@/utils";
|
||||
|
||||
interface TagProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const colors: Array<
|
||||
| "ruby"
|
||||
| "gray"
|
||||
| "gold"
|
||||
| "bronze"
|
||||
| "brown"
|
||||
| "yellow"
|
||||
| "amber"
|
||||
| "orange"
|
||||
| "tomato"
|
||||
| "red"
|
||||
| "crimson"
|
||||
| "pink"
|
||||
| "plum"
|
||||
| "purple"
|
||||
| "violet"
|
||||
| "iris"
|
||||
| "indigo"
|
||||
| "blue"
|
||||
| "cyan"
|
||||
| "teal"
|
||||
| "jade"
|
||||
| "green"
|
||||
| "grass"
|
||||
| "lime"
|
||||
| "mint"
|
||||
| "sky"
|
||||
> = [
|
||||
"ruby",
|
||||
"gray",
|
||||
"gold",
|
||||
"bronze",
|
||||
"brown",
|
||||
"yellow",
|
||||
"amber",
|
||||
"orange",
|
||||
"tomato",
|
||||
"red",
|
||||
"crimson",
|
||||
"pink",
|
||||
"plum",
|
||||
"purple",
|
||||
"violet",
|
||||
"iris",
|
||||
"indigo",
|
||||
"blue",
|
||||
"cyan",
|
||||
"teal",
|
||||
"jade",
|
||||
"green",
|
||||
"grass",
|
||||
"lime",
|
||||
"mint",
|
||||
"sky",
|
||||
];
|
||||
|
||||
// 解析带颜色的标签
|
||||
const parseTagWithColor = (tag: string) => {
|
||||
const colorMatch = tag.match(/<(\w+)>$/);
|
||||
if (colorMatch) {
|
||||
const color = colorMatch[1].toLowerCase();
|
||||
const text = tag.replace(/<\w+>$/, "");
|
||||
// 检查颜色是否在支持的颜色列表中
|
||||
if (colors.includes(color as any)) {
|
||||
return { text, color: color as (typeof colors)[number] };
|
||||
}
|
||||
}
|
||||
return { text: tag, color: null };
|
||||
};
|
||||
|
||||
const Tag = React.forwardRef<HTMLDivElement, TagProps>(
|
||||
({ className, tags, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-wrap gap-1", className)}
|
||||
{...props}>
|
||||
{tags.map((tag, index) => {
|
||||
const { text, color } = parseTagWithColor(tag);
|
||||
const badgeColor = color || colors[index % colors.length];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={index}
|
||||
color={badgeColor}
|
||||
variant="soft"
|
||||
className="text-sm">
|
||||
<label className="text-xs">{text}</label>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Tag.displayName = "Tag";
|
||||
|
||||
export { Tag };
|
Reference in New Issue
Block a user