4 Commits

Author SHA1 Message Date
typist
b5b9d94119 chore: update Gitea CI/CD workflow to use new variable syntax for Docker registry and metadata
Some checks failed
Build and Push Docker Image / build (push) Failing after 4m3s
2025-10-27 20:33:06 +08:00
typist
d5959ca3c3 chore: update Gitea CI/CD workflow to conditionally enable 'latest' Docker image tag based on branch name
Some checks failed
Build and Push Docker Image / build (push) Failing after 2m1s
2025-10-27 20:20:59 +08:00
185ce1fac1 Merge pull request 'feat: add Docker support with multi-stage build and CI/CD workflow' (#2) from chore/build-and-push-docker-image into main
Reviewed-on: #2
2025-10-27 20:17:04 +08:00
typist
c11bb6d637 feat: add Docker support with multi-stage build and CI/CD workflow
- Introduced Dockerfile for multi-stage application build and deployment using Nginx.
- Added .dockerignore to exclude unnecessary files from the Docker context.
- Created nginx.conf for server configuration, including Gzip compression and SPA routing.
- Implemented Gitea CI/CD workflow for building and pushing Docker images on version tags.
2025-10-27 20:16:22 +08:00
43 changed files with 33 additions and 8126 deletions

View File

@@ -19,16 +19,16 @@ jobs:
- name: 登录到 Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_ENDPOINT }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
registry: ${{ gitea.server_url }}
username: ${{ gitea.actor }}
password: ${{ gitea.token }}
- name: 提取 Docker 元数据
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek
${{ gitea.server_url }}/${{ gitea.repository_owner }}/litek
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
@@ -36,22 +36,13 @@ jobs:
type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }}
- name: 构建并推送 Docker 镜像
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache
# cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max
# platforms: linux/amd64,linux/arm64
platforms: linux/amd64
- name: 触发部署 workflow
uses: benc-uk/workflow-dispatch@v1
with:
workflow: deploy.yaml
token: ${{ secrets.GITHUB_TOKEN }}
inputs: '{"image_tag": "${{ github.ref_name }}"}'
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

View File

@@ -1,82 +0,0 @@
name: Deploy to Production
on:
workflow_dispatch:
inputs:
image_tag:
description: '要部署的镜像标签 (例如: v1.0.0, latest)'
required: true
default: 'latest'
compose_path:
description: 'docker-compose 文件路径'
required: true
default: '/root/.compose/litek/compose.yaml'
workflow_call:
inputs:
image_tag:
description: '要部署的镜像标签'
required: true
type: string
compose_path:
description: 'docker-compose 文件路径'
required: false
type: string
default: '/root/.compose/litek/compose.yaml'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 解析 SSH 连接信息
id: ssh-info
run: |
SSH_CONN="${{ secrets.SSH_CONNECTION }}"
# 格式: user@host:port
SSH_USER=$(echo $SSH_CONN | cut -d'@' -f1)
SSH_HOST_PORT=$(echo $SSH_CONN | cut -d'@' -f2)
SSH_HOST=$(echo $SSH_HOST_PORT | cut -d':' -f1)
SSH_PORT=$(echo $SSH_HOST_PORT | cut -d':' -f2)
echo "user=$SSH_USER" >> $GITHUB_OUTPUT
echo "host=$SSH_HOST" >> $GITHUB_OUTPUT
echo "port=$SSH_PORT" >> $GITHUB_OUTPUT
echo "SSH 连接信息已解析: $SSH_USER@$SSH_HOST:$SSH_PORT"
- name: 部署到生产服务器
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ steps.ssh-info.outputs.host }}
username: ${{ steps.ssh-info.outputs.user }}
port: ${{ steps.ssh-info.outputs.port }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
echo "开始部署 litek 应用..."
echo "镜像标签: ${{ inputs.image_tag }}"
echo "Compose 文件: ${{ inputs.compose_path }}"
# 切换到 compose 文件所在目录
COMPOSE_DIR=$(dirname "${{ inputs.compose_path }}")
cd "$COMPOSE_DIR"
echo "当前目录: $(pwd)"
# 拉取最新镜像
echo "正在拉取镜像..."
docker compose -f "${{ inputs.compose_path }}" pull
# 更新服务
echo "正在更新服务..."
docker compose -f "${{ inputs.compose_path }}" up -d
# 清理旧镜像
echo "清理未使用的镜像..."
docker image prune -f
echo "部署完成!"
# 显示运行状态
echo "当前运行的容器:"
docker compose -f "${{ inputs.compose_path }}" ps

View File

@@ -2,7 +2,7 @@
FROM node:22-alpine AS builder
# 安装 pnpm
RUN npm install -g pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 设置工作目录
WORKDIR /app
@@ -11,8 +11,7 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# 安装依赖
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile
# 复制源代码
COPY . .

View File

@@ -2,66 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
<title>Lite Kit - Lightweight Online Tools</title>
<meta name="description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more. Fast, secure, and easy to use." />
<meta name="keywords" content="online tools,UUID generator,JSON formatter,Base64 encoder,network tools,DNS lookup,Ping test,TCPing,speed test,IP query" />
<meta name="author" content="Lite Kit" />
<meta name="theme-color" content="#000000" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/lite.svg" />
<!-- DNS Prefetch & Preconnect for external APIs -->
<link rel="dns-prefetch" href="https://ipinfo.io">
<link rel="preconnect" href="https://ipinfo.io" crossorigin>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://litek.typist.cc/" />
<meta property="og:title" content="Lite Kit - Lightweight Online Tools" />
<meta property="og:description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more." />
<meta property="og:image" content="https://litek.typist.cc/lite.svg" />
<meta property="og:site_name" content="Lite Kit" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Lite Kit - Lightweight Online Tools" />
<meta name="twitter:description" content="Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more." />
<meta name="twitter:image" content="https://litek.typist.cc/lite.svg" />
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Lite Kit",
"description": "Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more",
"url": "https://litek.typist.cc/",
"author": {
"@type": "Organization",
"name": "Lite Kit"
},
"applicationCategory": "UtilitiesApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lite Kit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- Cloudflare Web Analytics -->
<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "2aecdc025eb043bc89ce931b54a80054"}'></script>
<!-- End Cloudflare Web Analytics -->
</body>
</html>

View File

@@ -1,38 +1,24 @@
{
"name": "litek",
"private": true,
"version": "0.0.25",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"generate:sitemap": "tsx scripts/generate-sitemap.ts",
"lint": "eslint .",
"preview": "vite preview",
"release:patch": "npm version patch && git push --follow-tags",
"release:minor": "npm version minor && git push --follow-tags",
"release:major": "npm version major && git push --follow-tags"
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.548.0",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.16",
"uuid": "^13.0.0"
"tailwindcss": "^4.1.16"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -44,22 +30,17 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"terser": "^5.44.0",
"tsx": "^4.19.2",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "npm:rolldown-vite@7.1.14",
"vite-plugin-pwa": "^1.1.0",
"workbox-window": "^7.3.0"
"vite": "npm:rolldown-vite@7.1.14"
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
},
"onlyBuiltDependencies": [
"@swc/core",
"esbuild"
"@swc/core"
]
}
}

3977
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,7 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<style>
/* 默认浅色模式使用黑色 */
path {
fill: #000000;
}
/* 暗色模式使用白色 */
@media (prefers-color-scheme: dark) {
path {
fill: #ffffff;
}
}
</style>
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier"> <path d="M12.6089 6.12601C12.6785 5.85878 12.5183 5.58573 12.251 5.51614C11.9838 5.44655 11.7108 5.60676 11.6412 5.87399L10.0842 11.8528L7.85646 12.5211C7.59196 12.6005 7.44187 12.8792 7.52122 13.1437C7.60057 13.4082 7.87931 13.5583 8.14381 13.479L9.78926 12.9853L8.51617 17.874C8.47715 18.0238 8.50974 18.1833 8.60443 18.3058C8.69911 18.4283 8.8452 18.5 9.00003 18.5H16C16.2762 18.5 16.5 18.2761 16.5 18C16.5 17.7239 16.2762 17.5 16 17.5H9.64691L10.9102 12.6491L13.1438 11.979C13.4083 11.8996 13.5584 11.6209 13.479 11.3564C13.3997 11.0919 13.121 10.9418 12.8565 11.0211L11.2051 11.5165L12.6089 6.12601Z"/> </g>
</svg>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,21 +0,0 @@
{
"name": "Lite Kit - Lightweight Online Tools",
"short_name": "Lite Kit",
"description": "Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/lite.svg",
"type": "image/svg+xml",
"sizes": "any"
}
],
"categories": ["utilities", "productivity"],
"lang": "en",
"dir": "ltr"
}

View File

@@ -1,21 +0,0 @@
# Allow all crawlers
User-agent: *
Allow: /
# Sitemaps
Sitemap: https://litek.typist.cc/sitemap.xml
# Common bots
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Baiduspider
Allow: /
# Crawl-delay for less aggressive bots
User-agent: *
Crawl-delay: 1

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://litek.typist.cc</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/uuid</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/json</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/base64</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/network/dns</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/network/ping</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/network/tcping</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/network/speedtest</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://litek.typist.cc/tool/network/ipquery</loc>
<lastmod>2025-10-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

View File

@@ -1,83 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
interface SitemapUrl {
loc: string;
lastmod: string;
changefreq: string;
priority: string;
}
const BASE_URL = 'https://litek.typist.cc';
const currentDate = new Date().toISOString().split('T')[0];
const tools = [
{ path: '/tool/uuid', priority: '0.9' },
{ path: '/tool/json', priority: '0.9' },
{ path: '/tool/base64', priority: '0.9' },
{ path: '/tool/network/dns', priority: '0.8' },
{ path: '/tool/network/ping', priority: '0.8' },
{ path: '/tool/network/tcping', priority: '0.8' },
{ path: '/tool/network/speedtest', priority: '0.8' },
{ path: '/tool/network/ipquery', priority: '0.8' },
];
const urls: SitemapUrl[] = [
{
loc: BASE_URL,
lastmod: currentDate,
changefreq: 'weekly',
priority: '1.0',
},
{
loc: `${BASE_URL}/tool`,
lastmod: currentDate,
changefreq: 'weekly',
priority: '0.9',
},
];
// Add all tool pages
tools.forEach((tool) => {
urls.push({
loc: `${BASE_URL}${tool.path}`,
lastmod: currentDate,
changefreq: 'monthly',
priority: tool.priority,
});
});
function generateSitemap(): string {
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
urls.forEach((url) => {
xml += ' <url>\n';
xml += ` <loc>${url.loc}</loc>\n`;
xml += ` <lastmod>${url.lastmod}</lastmod>\n`;
xml += ` <changefreq>${url.changefreq}</changefreq>\n`;
xml += ` <priority>${url.priority}</priority>\n`;
xml += ' </url>\n';
});
xml += '</urlset>\n';
return xml;
}
// Generate and write sitemap
const sitemap = generateSitemap();
const publicDir = path.resolve(__dirname, '../public');
const sitemapPath = path.join(publicDir, 'sitemap.xml');
// Ensure public directory exists
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
fs.writeFileSync(sitemapPath, sitemap, 'utf-8');
console.log(`✅ Sitemap generated successfully at ${sitemapPath}`);

View File

@@ -1,124 +0,0 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { tools, type Tool } from "@/components/tool";
export const AppSidebar = () => {
// 递归构建完整路径
const buildFullPath = (pathSegments: string[]): string => {
return `/tool/${pathSegments.join("/")}`;
};
// 递归渲染子菜单内容
const renderSubMenuContent = (child: Tool, currentPaths: string[]): ReactNode => {
if (child.children) {
// 子菜单内的可折叠项
return (
<Collapsible defaultOpen={false} className="group/collapsible">
<CollapsibleTrigger asChild>
<SidebarMenuSubButton>
{child.icon}
<span>{child.name}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuSubButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{child.children.map((subChild) => (
<SidebarMenuSubItem key={subChild.name}>
{renderSubMenuContent(subChild, [...currentPaths, child.path])}
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
);
}
// 叶子节点
return (
<SidebarMenuSubButton asChild>
<Link
to={buildFullPath([...currentPaths, child.path])}
title={child.description}
>
{child.icon}
<span>{child.name}</span>
</Link>
</SidebarMenuSubButton>
);
};
// 递归渲染菜单项
const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => {
const currentPaths = [...parentPaths, tool.path];
if (tool.children) {
// 有子菜单的项目
return (
<SidebarMenuItem key={tool.name}>
<Collapsible
defaultOpen={false}
className="group/collapsible"
>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={tool.description}>
{tool.icon}
<span>{tool.name}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{tool.children.map((child) => (
<SidebarMenuSubItem key={child.name}>
{renderSubMenuContent(child, currentPaths)}
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
);
}
// 没有子菜单的项目
return (
<SidebarMenuItem key={tool.name}>
<SidebarMenuButton asChild tooltip={tool.description}>
<Link to={buildFullPath(currentPaths)}>
{tool.icon}
<span>{tool.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
};
return (
<Sidebar>
<SidebarHeader className="text-2xl font-bold flex justify-center items-center">
Lite Kit
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Tools</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{tools.map((tool) => renderMenuItem(tool))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="flex flex-row justify-between items-center gap-2">
<SidebarMenuButton variant="outline" asChild>
<a href="mailto:litek@mail.typist.cc">need more tools?</a>
</SidebarMenuButton>
</SidebarFooter>
</Sidebar>
);
};

View File

@@ -1,73 +0,0 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -1,38 +0,0 @@
import { Moon, Sun } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "./provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,70 +0,0 @@
import { useState, type FC } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
const Tool: FC = () => {
const [decoded, setDecoded] = useState<string>("");
const [encoded, setEncoded] = useState<string>("");
const encode = () => {
try {
const encoded64 = btoa(decoded);
setEncoded(encoded64);
setDecoded("");
toast.success("encoded successfully");
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("encoding failed");
}
}
};
const decode = () => {
try {
const decoded64 = atob(encoded);
setDecoded(decoded64);
setEncoded("");
toast.success("decoded successfully");
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("decoding failed");
}
}
};
return (
<div className="h-[50vh] flex flex-row gap-4 pt-[20vh]">
<Textarea
className="flex-1 resize-none"
placeholder="Enter the original text"
value={decoded}
onChange={(e) => setDecoded(e.target.value)}
/>
<div className="flex flex-col gap-2 justify-center">
<Button onClick={encode}>
<ArrowRightIcon className="size-4" />
Encode
</Button>
<Button onClick={decode}>
<ArrowLeftIcon className="size-4" />
Decode
</Button>
</div>
<Textarea
className="flex-1 resize-none"
placeholder="Enter the Base64 encoded text"
value={encoded}
onChange={(e) => setEncoded(e.target.value)}
/>
</div>
);
};
export default Tool;

View File

@@ -1,88 +0,0 @@
import { lazy, type ReactNode, type ComponentType } from 'react';
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react'
// 懒加载工具组件
const UUID = lazy(() => import('./uuid'))
const JSON = lazy(() => import('./json'))
const Base64 = lazy(() => import('./base64'))
const DNS = lazy(() => import('./network/dns'))
const Ping = lazy(() => import('./network/ping'))
const TCPing = lazy(() => import('./network/tcping'))
const SpeedTest = lazy(() => import('./network/speedtest'))
const IPQuery = lazy(() => import('./network/ipquery'))
export interface Tool {
path: string;
name: string;
icon: ReactNode;
description: string;
component?: ComponentType;
children?: Tool[];
}
export const tools: Tool[] = [
{
path: "uuid",
name: "UUID Generator",
description: "Generate a UUID",
icon: <Hash />,
component: UUID,
},
{
path: "json",
name: "JSON Formatter",
description: "Format and validate JSON",
icon: <FileJson />,
component: JSON,
},
{
path: "base64",
name: "Base64 Encoder/Decoder",
description: "Encode and decode Base64",
icon: <Binary />,
component: Base64,
},
{
path: "network",
name: "Network Tools",
description: "Network testing tools",
icon: <Network />,
children: [
{
path: "dns",
name: "DNS Lookup",
description: "DNS query tool",
icon: <Globe />,
component: DNS,
},
{
path: "ping",
name: "Ping",
description: "Ping test tool",
icon: <Activity />,
component: Ping,
},
{
path: "tcping",
name: "TCPing",
description: "TCP port connectivity test",
icon: <Wifi />,
component: TCPing,
},
{
path: "speedtest",
name: "Speed Test",
description: "Website speed test",
icon: <Gauge />,
component: SpeedTest,
},
{
path: "ipquery",
name: "IP Query",
description: "Query IP location, quality and risk info",
icon: <MapPin />,
component: IPQuery,
},
],
},
];

View File

@@ -1,48 +0,0 @@
import { useState, type FC } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
const Tool: FC = () => {
const [json, setJson] = useState<string>("");
const validateJson = () => {
try {
JSON.parse(json);
return true;
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error("Invalid JSON");
}
return false;
}
};
const minifyJson = () => {
if (!validateJson()) return;
const formattedJson = JSON.stringify(JSON.parse(json), null, 0);
setJson(formattedJson);
toast.success("Minified successfully");
};
const prettifyJson = () => {
if (!validateJson()) return;
const formattedJson = JSON.stringify(JSON.parse(json), null, 2);
setJson(formattedJson);
toast.success("JSON prettified successfully");
};
return (
<div className="h-full flex flex-col gap-4">
<Textarea className="flex-1 w-full resize-none" placeholder="Enter your JSON here" value={json} onChange={(e) => setJson(e.target.value)} />
<div className="flex flex-row gap-2">
<Button onClick={minifyJson}>Minify</Button>
<Button onClick={prettifyJson}>Pretty</Button>
</div>
</div>
);
};
export default Tool;

View File

@@ -1,200 +0,0 @@
import { useState, type FC } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface DNSRecord {
name: string;
type: number;
TTL: number;
data: string;
}
interface DNSResponse {
Status: number;
Answer?: DNSRecord[];
Question?: Array<{ name: string; type: number }>;
}
const DNS_RECORD_TYPES = [
{ value: "1", label: "A", description: "IPv4 Address" },
{ value: "28", label: "AAAA", description: "IPv6 Address" },
{ value: "5", label: "CNAME", description: "Canonical Name" },
{ value: "15", label: "MX", description: "Mail Exchange" },
{ value: "2", label: "NS", description: "Name Server" },
{ value: "16", label: "TXT", description: "Text Record" },
{ value: "6", label: "SOA", description: "Start of Authority" },
{ value: "257", label: "CAA", description: "Certification Authority Authorization" },
{ value: "12", label: "PTR", description: "Pointer Record" },
{ value: "33", label: "SRV", description: "Service Record" },
];
const getRecordTypeName = (type: number): string => {
const record = DNS_RECORD_TYPES.find((r) => r.value === String(type));
return record ? record.label : `TYPE${type}`;
};
const Tool: FC = () => {
const [domain, setDomain] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [results, setResults] = useState<DNSRecord[]>([]);
const [queryTime, setQueryTime] = useState<number>(0);
const handleDomainBlur = () => {
if (!domain.trim()) return;
let input = domain.trim();
let cleanDomain = input;
try {
// Try to parse as URL
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
cleanDomain = url.hostname;
} catch {
// If parsing fails, fallback to manual cleanup
cleanDomain = input.replace(/^https?:\/\//, "").split("/")[0].split(":")[0];
}
if (cleanDomain !== input) {
setDomain(cleanDomain);
}
};
const queryDNS = async () => {
if (!domain.trim()) {
toast.error("Please enter a domain name");
return;
}
setLoading(true);
setResults([]);
setQueryTime(0);
const startTime = performance.now();
try {
// Query all record types concurrently
const queries = DNS_RECORD_TYPES.map((recordType) =>
fetch(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(
domain.trim()
)}&type=${recordType.value}`,
{
headers: {
Accept: "application/dns-json",
},
}
)
.then((response) => response.json())
.then((data: DNSResponse) => {
if (data.Status === 0 && data.Answer && data.Answer.length > 0) {
return data.Answer;
}
return [];
})
.catch(() => [])
);
const allResults = await Promise.all(queries);
const endTime = performance.now();
setQueryTime(endTime - startTime);
// Merge and deduplicate results
const combinedResults = allResults.flat();
if (combinedResults.length > 0) {
// Group by record type and deduplicate
const uniqueResults = Array.from(
new Map(
combinedResults.map((record) => [
`${record.name}-${record.type}-${record.data}`,
record,
])
).values()
);
setResults(uniqueResults);
toast.success(`Query successful, found ${uniqueResults.length} record(s)`);
} else {
setResults([]);
toast.info("No records found");
}
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(`Query failed: ${error.message}`);
} else {
toast.error("Query failed");
}
setResults([]);
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !loading) {
queryDNS();
}
};
return (
<div className="flex flex-col gap-4 h-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Domain Name</label>
<Input
placeholder="e.g. example.com"
value={domain}
onChange={(e) => setDomain(e.target.value)}
onBlur={handleDomainBlur}
onKeyPress={handleKeyPress}
disabled={loading}
/>
<span className="text-xs text-muted-foreground">
Will automatically query all DNS record types
</span>
</div>
<Button onClick={queryDNS} disabled={loading} className="w-full">
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
{loading ? "Querying..." : "Query All Records"}
</Button>
</div>
{queryTime > 0 && (
<div className="text-sm text-muted-foreground">
Query time: {queryTime.toFixed(2)} ms
</div>
)}
{results.length > 0 && (
<div className="flex flex-col gap-3 flex-1 overflow-auto">
<div className="text-sm font-medium">Query Results:</div>
<div className="space-y-2">
{results.map((record, index) => (
<div
key={index}
className="border rounded-md p-3 bg-card text-card-foreground"
>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Name:</div>
<div className="font-mono break-all">{record.name}</div>
<div className="text-muted-foreground">Type:</div>
<div>{getRecordTypeName(record.type)}</div>
<div className="text-muted-foreground">TTL:</div>
<div>{record.TTL} seconds</div>
<div className="text-muted-foreground">Data:</div>
<div className="font-mono break-all">{record.data}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default Tool;

View File

@@ -1,296 +0,0 @@
import { useState, type FC } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface IPInfo {
ip: string;
city?: string;
region?: string;
country?: string;
countryCode?: string;
loc?: string;
org?: string;
timezone?: string;
isp?: string;
as?: string;
proxy?: boolean;
hosting?: boolean;
query?: string;
}
const Tool: FC = () => {
const [ip, setIp] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [ipInfo, setIpInfo] = useState<IPInfo | null>(null);
const [queryTime, setQueryTime] = useState<number>(0);
const isValidIP = (ip: string): boolean => {
// IPv4 正则
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
// IPv6 正则 (简化版)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (ipv4Regex.test(ip)) {
const parts = ip.split('.');
return parts.every(part => parseInt(part) >= 0 && parseInt(part) <= 255);
}
return ipv6Regex.test(ip);
};
const queryCurrentIP = async () => {
setLoading(true);
setIpInfo(null);
setQueryTime(0);
const startTime = performance.now();
try {
// 使用 ipinfo.io 查询当前IP (免费,无需密钥)
const response = await fetch("https://ipinfo.io/json");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const endTime = performance.now();
setQueryTime(endTime - startTime);
setIpInfo(data);
setIp(data.ip);
toast.success("Successfully queried current IP");
} catch (error) {
if (error instanceof Error) {
toast.error(`Query failed: ${error.message}`);
} else {
toast.error("Query failed");
}
} finally {
setLoading(false);
}
};
const queryIP = async () => {
if (!ip.trim()) {
toast.error("Please enter an IP address");
return;
}
if (!isValidIP(ip.trim())) {
toast.error("Invalid IP address format");
return;
}
setLoading(true);
setIpInfo(null);
setQueryTime(0);
const startTime = performance.now();
try {
// 使用 ipinfo.io (免费,稳定可靠)
const response = await fetch(`https://ipinfo.io/${encodeURIComponent(ip.trim())}/json`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const endTime = performance.now();
setQueryTime(endTime - startTime);
// ipinfo.io 返回格式已经符合 IPInfo 接口
setIpInfo(data);
toast.success("Query successful");
} catch (error) {
if (error instanceof Error) {
toast.error(`Query failed: ${error.message}`);
} else {
toast.error("Query failed");
}
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !loading) {
queryIP();
}
};
const getRiskLevel = () => {
if (!ipInfo) return null;
// ipinfo.io 通过 org 字段可以简单判断是否为托管IP
const orgLower = ipInfo.org?.toLowerCase() || "";
const isHosting = orgLower.includes("hosting") ||
orgLower.includes("datacenter") ||
orgLower.includes("cloud") ||
orgLower.includes("server");
if (isHosting) {
return {
level: "Medium",
color: "text-yellow-500",
reasons: ["Possible Hosting/Datacenter IP"],
};
}
return {
level: "Low",
color: "text-green-500",
reasons: ["Regular residential IP"],
};
};
const riskInfo = getRiskLevel();
return (
<div className="flex flex-col gap-4 h-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">IP Address</label>
<Input
placeholder="e.g. 8.8.8.8 or leave empty for current IP"
value={ip}
onChange={(e) => setIp(e.target.value)}
onKeyPress={handleKeyPress}
disabled={loading}
/>
<span className="text-xs text-muted-foreground">
Supports IPv4 and IPv6 addresses
</span>
</div>
<div className="flex gap-2">
<Button onClick={queryIP} disabled={loading} className="flex-1">
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
{loading ? "Querying..." : "Query IP"}
</Button>
<Button
onClick={queryCurrentIP}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
Query My IP
</Button>
</div>
</div>
{queryTime > 0 && (
<div className="text-sm text-muted-foreground">
Query time: {queryTime.toFixed(2)} ms
</div>
)}
{ipInfo && (
<div className="flex flex-col gap-3 flex-1 overflow-auto">
<div className="text-sm font-medium">IP Information:</div>
{/* 基本信息 */}
<div className="border rounded-md p-4 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Basic Information</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">IP Address:</div>
<div className="font-mono">{ipInfo.ip || ipInfo.query}</div>
{ipInfo.country && (
<>
<div className="text-muted-foreground">Country:</div>
<div>{ipInfo.country} ({ipInfo.countryCode})</div>
</>
)}
{ipInfo.region && (
<>
<div className="text-muted-foreground">Region:</div>
<div>{ipInfo.region}</div>
</>
)}
{ipInfo.city && (
<>
<div className="text-muted-foreground">City:</div>
<div>{ipInfo.city}</div>
</>
)}
{ipInfo.loc && (
<>
<div className="text-muted-foreground">Coordinates:</div>
<div className="font-mono">{ipInfo.loc}</div>
</>
)}
{ipInfo.timezone && (
<>
<div className="text-muted-foreground">Timezone:</div>
<div>{ipInfo.timezone}</div>
</>
)}
</div>
</div>
{/* 网络信息 */}
{(ipInfo.isp || ipInfo.org || ipInfo.as) && (
<div className="border rounded-md p-4 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Network Information</div>
<div className="grid grid-cols-2 gap-2 text-sm">
{ipInfo.isp && (
<>
<div className="text-muted-foreground">ISP:</div>
<div>{ipInfo.isp}</div>
</>
)}
{ipInfo.org && (
<>
<div className="text-muted-foreground">Organization:</div>
<div>{ipInfo.org}</div>
</>
)}
{ipInfo.as && (
<>
<div className="text-muted-foreground">AS Number:</div>
<div className="font-mono">{ipInfo.as}</div>
</>
)}
</div>
</div>
)}
{/* 风险评估 */}
{riskInfo && (
<div className="border rounded-md p-4 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Risk Assessment</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Risk Level:</div>
<div className={`font-medium ${riskInfo.color}`}>
{riskInfo.level}
</div>
<div className="text-muted-foreground">Details:</div>
<div className="space-y-1">
{riskInfo.reasons.map((reason, idx) => (
<div key={idx} className="text-sm">{reason}</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default Tool;

View File

@@ -1,278 +0,0 @@
import { useState, useEffect, useRef, type FC } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface PingResult {
seq: number;
time: number;
success: boolean;
error?: string;
}
interface PingStats {
sent: number;
received: number;
lost: number;
min: number;
max: number;
avg: number;
}
const Tool: FC = () => {
const [url, setUrl] = useState<string>("");
const [running, setRunning] = useState<boolean>(false);
const [results, setResults] = useState<PingResult[]>([]);
const [stats, setStats] = useState<PingStats>({
sent: 0,
received: 0,
lost: 0,
min: 0,
max: 0,
avg: 0,
});
const intervalRef = useRef<number | null>(null);
const seqRef = useRef<number>(0);
const resultsContainerRef = useRef<HTMLDivElement>(null);
const handleUrlBlur = () => {
if (!url.trim()) return;
let input = url.trim();
try {
// Try to parse as URL
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
const normalizedUrl = parsedUrl.toString();
if (normalizedUrl !== input) {
setUrl(normalizedUrl);
}
} catch {
// If parsing fails, add https:// prefix
if (!input.startsWith("http://") && !input.startsWith("https://")) {
setUrl(`https://${input}`);
}
}
};
const ping = async () => {
if (!url.trim()) {
toast.error("Please enter a URL");
return;
}
const seq = ++seqRef.current;
const targetUrl = url.trim();
const startTime = performance.now();
try {
await fetch(targetUrl, {
method: "HEAD",
mode: "no-cors",
cache: "no-cache",
});
const endTime = performance.now();
const time = endTime - startTime;
const newResult: PingResult = {
seq,
time,
success: true,
};
setResults((prev) => [...prev, newResult]);
updateStats(newResult);
} catch (error: unknown) {
const endTime = performance.now();
const time = endTime - startTime;
const errorMessage =
error instanceof Error ? error.message : "Request failed";
const newResult: PingResult = {
seq,
time,
success: false,
error: errorMessage,
};
setResults((prev) => [...prev, newResult]);
updateStats(newResult);
}
};
const updateStats = (newResult: PingResult) => {
setStats((prev) => {
const sent = prev.sent + 1;
const received = newResult.success ? prev.received + 1 : prev.received;
const lost = sent - received;
let min = prev.min;
let max = prev.max;
let avg = prev.avg;
if (newResult.success) {
if (received === 1) {
min = newResult.time;
max = newResult.time;
avg = newResult.time;
} else {
min = Math.min(min, newResult.time);
max = Math.max(max, newResult.time);
avg = (prev.avg * (received - 1) + newResult.time) / received;
}
}
return { sent, received, lost, min, max, avg };
});
};
const startPing = () => {
if (!url.trim()) {
toast.error("Please enter a URL");
return;
}
setRunning(true);
setResults([]);
setStats({
sent: 0,
received: 0,
lost: 0,
min: 0,
max: 0,
avg: 0,
});
seqRef.current = 0;
// Execute first ping immediately
ping();
// Then execute every second
intervalRef.current = window.setInterval(ping, 1000);
};
const stopPing = () => {
setRunning(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
useEffect(() => {
// Auto-scroll to bottom
if (resultsContainerRef.current) {
resultsContainerRef.current.scrollTop =
resultsContainerRef.current.scrollHeight;
}
}, [results]);
useEffect(() => {
// Cleanup timer
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
const lossRate =
stats.sent > 0 ? ((stats.lost / stats.sent) * 100).toFixed(1) : "0.0";
return (
<div className="flex flex-col gap-4 h-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Target URL or IP</label>
<Input
placeholder="e.g. example.com or https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
disabled={running}
/>
</div>
<div className="flex gap-2">
{!running ? (
<Button onClick={startPing} className="flex-1">
Start Ping
</Button>
) : (
<Button onClick={stopPing} variant="destructive" className="flex-1">
Stop
</Button>
)}
</div>
</div>
{stats.sent > 0 && (
<div className="border rounded-md p-3 bg-card text-card-foreground">
<div className="text-sm font-medium mb-2">Statistics</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Sent:</div>
<div>{stats.sent} packets</div>
<div className="text-muted-foreground">Received:</div>
<div>{stats.received} packets</div>
<div className="text-muted-foreground">Lost:</div>
<div>
{stats.lost} packets ({lossRate}%)
</div>
{stats.received > 0 && (
<>
<div className="text-muted-foreground">Min Latency:</div>
<div>{stats.min.toFixed(2)} ms</div>
<div className="text-muted-foreground">Max Latency:</div>
<div>{stats.max.toFixed(2)} ms</div>
<div className="text-muted-foreground">Avg Latency:</div>
<div>{stats.avg.toFixed(2)} ms</div>
</>
)}
</div>
</div>
)}
{results.length > 0 && (
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
<div className="text-sm font-medium">Ping Results:</div>
<div
ref={resultsContainerRef}
className="flex-1 overflow-auto space-y-1 font-mono text-sm border rounded-md p-3 bg-card"
>
{results.map((result) => (
<div
key={result.seq}
className={result.success ? "text-green-500" : "text-red-500"}
>
{result.success ? (
<>
seq={result.seq} time={result.time.toFixed(2)}ms
</>
) : (
<>
seq={result.seq} Request timeout
{result.error && ` (${result.error})`}
</>
)}
</div>
))}
{running && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Running...
</div>
)}
</div>
</div>
)}
</div>
);
};
export default Tool;

View File

@@ -1,355 +0,0 @@
import { useState, type FC } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface PerformanceMetrics {
dns: number;
tcp: number;
ssl: number;
ttfb: number;
download: number;
total: number;
}
interface SpeedTestResult {
downloadSpeed?: number;
uploadSpeed?: number;
performance?: PerformanceMetrics;
}
const Tool: FC = () => {
const [url, setUrl] = useState<string>("");
const [testType, setTestType] = useState<"performance" | "download" | "upload">(
"performance"
);
const [testing, setTesting] = useState<boolean>(false);
const [result, setResult] = useState<SpeedTestResult | null>(null);
const testPerformance = async (targetUrl: string) => {
// 清除之前的性能数据
performance.clearResourceTimings();
const startTime = performance.now();
try {
const response = await fetch(targetUrl, {
cache: "no-cache",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// 等待内容加载完成
await response.blob();
const endTime = performance.now();
// 获取性能数据
const perfEntries = performance.getEntriesByType(
"resource"
) as PerformanceResourceTiming[];
const entry = perfEntries.find((e) => e.name === targetUrl);
if (entry) {
const metrics: PerformanceMetrics = {
dns: entry.domainLookupEnd - entry.domainLookupStart,
tcp: entry.connectEnd - entry.connectStart,
ssl:
entry.secureConnectionStart > 0
? entry.connectEnd - entry.secureConnectionStart
: 0,
ttfb: entry.responseStart - entry.requestStart,
download: entry.responseEnd - entry.responseStart,
total: entry.responseEnd - entry.startTime,
};
return { performance: metrics };
} else {
// If no detailed performance data, only return total time
return {
performance: {
dns: 0,
tcp: 0,
ssl: 0,
ttfb: 0,
download: 0,
total: endTime - startTime,
},
};
}
} catch (error) {
throw error;
}
};
const testDownloadSpeed = async (targetUrl: string) => {
const startTime = performance.now();
try {
const response = await fetch(targetUrl, {
cache: "no-cache",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const blob = await response.blob();
const endTime = performance.now();
const fileSizeBytes = blob.size;
const durationSeconds = (endTime - startTime) / 1000;
const speedMbps = (fileSizeBytes * 8) / (durationSeconds * 1000000);
return { downloadSpeed: speedMbps };
} catch (error) {
throw error;
}
};
const testUploadSpeed = async (targetUrl: string) => {
// 生成 1MB 的测试数据
const testData = new Uint8Array(1024 * 1024);
for (let i = 0; i < testData.length; i++) {
testData[i] = Math.floor(Math.random() * 256);
}
const startTime = performance.now();
try {
const response = await fetch(targetUrl, {
method: "POST",
body: testData,
cache: "no-cache",
});
const endTime = performance.now();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const fileSizeBytes = testData.length;
const durationSeconds = (endTime - startTime) / 1000;
const speedMbps = (fileSizeBytes * 8) / (durationSeconds * 1000000);
return { uploadSpeed: speedMbps };
} catch (error) {
throw error;
}
};
const handleUrlBlur = () => {
if (!url.trim()) return;
let input = url.trim();
try {
// Try to parse as URL
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
const normalizedUrl = parsedUrl.toString();
if (normalizedUrl !== input) {
setUrl(normalizedUrl);
}
} catch {
// If parsing fails, add https:// prefix
if (!input.startsWith("http://") && !input.startsWith("https://")) {
setUrl(`https://${input}`);
}
}
};
const startTest = async () => {
if (!url.trim()) {
toast.error("Please enter a URL");
return;
}
const targetUrl = url.trim();
setTesting(true);
setResult(null);
try {
let testResult: SpeedTestResult = {};
switch (testType) {
case "performance":
testResult = await testPerformance(targetUrl);
toast.success("Performance test completed");
break;
case "download":
testResult = await testDownloadSpeed(targetUrl);
toast.success("Download speed test completed");
break;
case "upload":
testResult = await testUploadSpeed(targetUrl);
toast.success("Upload speed test completed");
break;
}
setResult(testResult);
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(`Test failed: ${error.message}`);
} else {
toast.error("Test failed");
}
} finally {
setTesting(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !testing) {
startTest();
}
};
return (
<div className="flex flex-col gap-4 h-full">
<div className="border rounded-md p-3 bg-yellow-500/10 border-yellow-500/50">
<div className="text-sm font-medium text-yellow-600 dark:text-yellow-400 mb-1">
CORS Restrictions
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>Due to browser CORS security policies, some websites cannot be tested directly.</p>
<p>Recommended websites to test:</p>
<ul className="list-disc list-inside ml-2">
<li>Public APIs with CORS support</li>
<li>Your own websites (with configured CORS headers)</li>
<li>Using CORS proxy services</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Target URL</label>
<Input
placeholder="e.g. https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
onKeyPress={handleKeyPress}
disabled={testing}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Test Type</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={testType}
onChange={(e) =>
setTestType(e.target.value as "performance" | "download" | "upload")
}
disabled={testing}
>
<option value="performance">Page Load Performance</option>
<option value="download">Download Speed</option>
<option value="upload">Upload Speed</option>
</select>
</div>
<Button onClick={startTest} disabled={testing} className="w-full">
{testing && <Loader2 className="mr-2 size-4 animate-spin" />}
{testing ? "Testing..." : "Start Test"}
</Button>
</div>
{result && (
<div className="flex flex-col gap-3 flex-1 overflow-auto">
<div className="text-sm font-medium">Test Results:</div>
{result.performance && (
<div className="border rounded-md p-3 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Page Load Performance</div>
<div className="space-y-2">
{result.performance.dns > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">DNS Lookup:</div>
<div>{result.performance.dns.toFixed(2)} ms</div>
</div>
)}
{result.performance.tcp > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">TCP Connection:</div>
<div>{result.performance.tcp.toFixed(2)} ms</div>
</div>
)}
{result.performance.ssl > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">SSL Handshake:</div>
<div>{result.performance.ssl.toFixed(2)} ms</div>
</div>
)}
{result.performance.ttfb > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Time to First Byte (TTFB):</div>
<div>{result.performance.ttfb.toFixed(2)} ms</div>
</div>
)}
{result.performance.download > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Content Download:</div>
<div>{result.performance.download.toFixed(2)} ms</div>
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm border-t pt-2 mt-2">
<div className="text-muted-foreground font-medium">Total Time:</div>
<div className="font-medium">
{result.performance.total.toFixed(2)} ms
</div>
</div>
</div>
</div>
)}
{result.downloadSpeed !== undefined && (
<div className="border rounded-md p-3 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Download Speed</div>
<div className="text-2xl font-bold text-green-500">
{result.downloadSpeed.toFixed(2)} Mbps
</div>
<div className="text-sm text-muted-foreground mt-1">
{(result.downloadSpeed / 8).toFixed(2)} MB/s
</div>
</div>
)}
{result.uploadSpeed !== undefined && (
<div className="border rounded-md p-3 bg-card text-card-foreground">
<div className="text-sm font-medium mb-3">Upload Speed</div>
<div className="text-2xl font-bold text-blue-500">
{result.uploadSpeed.toFixed(2)} Mbps
</div>
<div className="text-sm text-muted-foreground mt-1">
{(result.uploadSpeed / 8).toFixed(2)} MB/s
</div>
</div>
)}
</div>
)}
{testType === "download" && (
<div className="text-xs text-muted-foreground">
Note: Download speed test will download content from the target URL and calculate speed
</div>
)}
{testType === "upload" && (
<div className="text-xs text-muted-foreground">
Note: Upload speed test will send 1MB of test data to the target URL
</div>
)}
</div>
);
};
export default Tool;

View File

@@ -1,323 +0,0 @@
import { useState, useEffect, useRef, type FC } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
interface TCPingResult {
seq: number;
time: number;
success: boolean;
error?: string;
}
interface TCPingStats {
sent: number;
received: number;
lost: number;
min: number;
max: number;
avg: number;
}
const Tool: FC = () => {
const [host, setHost] = useState<string>("");
const [port, setPort] = useState<string>("443");
const [running, setRunning] = useState<boolean>(false);
const [results, setResults] = useState<TCPingResult[]>([]);
const [stats, setStats] = useState<TCPingStats>({
sent: 0,
received: 0,
lost: 0,
min: 0,
max: 0,
avg: 0,
});
const intervalRef = useRef<number | null>(null);
const seqRef = useRef<number>(0);
const resultsContainerRef = useRef<HTMLDivElement>(null);
const handleHostBlur = () => {
if (!host.trim()) return;
let input = host.trim();
let cleanHost = input;
let extractedPort: string | null = null;
try {
// Try to parse as URL
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
cleanHost = url.hostname;
// Extract port if specified in URL
if (url.port) {
extractedPort = url.port;
}
} catch {
// If parsing fails, fallback to manual cleanup
const withoutProtocol = input.replace(/^https?:\/\//, "");
const withoutPath = withoutProtocol.split("/")[0];
// Check for port in the format hostname:port
const portMatch = withoutPath.match(/^(.+):(\d+)$/);
if (portMatch) {
cleanHost = portMatch[1];
extractedPort = portMatch[2];
} else {
cleanHost = withoutPath;
}
}
if (cleanHost !== input) {
setHost(cleanHost);
}
if (extractedPort) {
setPort(extractedPort);
}
};
const tcping = async () => {
if (!host.trim()) {
toast.error("Please enter a hostname or IP");
return;
}
const seq = ++seqRef.current;
const portNum = parseInt(port) || 443;
const targetHost = host.trim();
// Build test URL
const protocol = portNum === 443 ? "https" : "http";
const url = `${protocol}://${targetHost}:${portNum}`;
const startTime = performance.now();
try {
// 使用 fetch 测试连接
await fetch(url, {
method: "HEAD",
mode: "no-cors",
cache: "no-cache",
});
const endTime = performance.now();
const time = endTime - startTime;
const newResult: TCPingResult = {
seq,
time,
success: true,
};
setResults((prev) => [...prev, newResult]);
updateStats(newResult);
} catch (error: unknown) {
const endTime = performance.now();
const time = endTime - startTime;
const errorMessage =
error instanceof Error ? error.message : "Connection failed";
const newResult: TCPingResult = {
seq,
time,
success: false,
error: errorMessage,
};
setResults((prev) => [...prev, newResult]);
updateStats(newResult);
}
};
const updateStats = (newResult: TCPingResult) => {
setStats((prev) => {
const sent = prev.sent + 1;
const received = newResult.success ? prev.received + 1 : prev.received;
const lost = sent - received;
let min = prev.min;
let max = prev.max;
let avg = prev.avg;
if (newResult.success) {
if (received === 1) {
min = newResult.time;
max = newResult.time;
avg = newResult.time;
} else {
min = Math.min(min, newResult.time);
max = Math.max(max, newResult.time);
avg = (prev.avg * (received - 1) + newResult.time) / received;
}
}
return { sent, received, lost, min, max, avg };
});
};
const startTCPing = () => {
if (!host.trim()) {
toast.error("Please enter a hostname or IP");
return;
}
setRunning(true);
setResults([]);
setStats({
sent: 0,
received: 0,
lost: 0,
min: 0,
max: 0,
avg: 0,
});
seqRef.current = 0;
// 立即执行第一次 tcping
tcping();
// 然后每秒执行一次
intervalRef.current = window.setInterval(tcping, 1000);
};
const stopTCPing = () => {
setRunning(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
useEffect(() => {
// 自动滚动到底部
if (resultsContainerRef.current) {
resultsContainerRef.current.scrollTop =
resultsContainerRef.current.scrollHeight;
}
}, [results]);
useEffect(() => {
// 清理定时器
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
const lossRate =
stats.sent > 0 ? ((stats.lost / stats.sent) * 100).toFixed(1) : "0.0";
return (
<div className="flex flex-col gap-4 h-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Hostname or IP</label>
<Input
placeholder="e.g. example.com or 192.168.1.1"
value={host}
onChange={(e) => setHost(e.target.value)}
onBlur={handleHostBlur}
disabled={running}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Port</label>
<Input
type="number"
placeholder="e.g. 443"
value={port}
onChange={(e) => setPort(e.target.value)}
disabled={running}
min="1"
max="65535"
/>
</div>
<div className="flex gap-2">
{!running ? (
<Button onClick={startTCPing} className="flex-1">
Start Test
</Button>
) : (
<Button
onClick={stopTCPing}
variant="destructive"
className="flex-1"
>
Stop
</Button>
)}
</div>
</div>
{stats.sent > 0 && (
<div className="border rounded-md p-3 bg-card text-card-foreground">
<div className="text-sm font-medium mb-2">Statistics</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Sent:</div>
<div>{stats.sent} times</div>
<div className="text-muted-foreground">Success:</div>
<div>{stats.received} times</div>
<div className="text-muted-foreground">Failed:</div>
<div>
{stats.lost} times ({lossRate}%)
</div>
{stats.received > 0 && (
<>
<div className="text-muted-foreground">Min Latency:</div>
<div>{stats.min.toFixed(2)} ms</div>
<div className="text-muted-foreground">Max Latency:</div>
<div>{stats.max.toFixed(2)} ms</div>
<div className="text-muted-foreground">Avg Latency:</div>
<div>{stats.avg.toFixed(2)} ms</div>
<div className="text-muted-foreground">Port Status:</div>
<div className="text-green-500 font-medium">Open</div>
</>
)}
</div>
</div>
)}
{results.length > 0 && (
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
<div className="text-sm font-medium">TCPing Results:</div>
<div
ref={resultsContainerRef}
className="flex-1 overflow-auto space-y-1 font-mono text-sm border rounded-md p-3 bg-card"
>
{results.map((result) => (
<div
key={result.seq}
className={result.success ? "text-green-500" : "text-red-500"}
>
{result.success ? (
<>
seq={result.seq} port={port} time={result.time.toFixed(2)}ms
</>
) : (
<>
seq={result.seq} port={port} Connection failed
{result.error && ` (${result.error})`}
</>
)}
</div>
))}
{running && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Running...
</div>
)}
</div>
</div>
)}
</div>
);
};
export default Tool;

View File

@@ -1,96 +0,0 @@
import { type FC, useState } from "react";
import { RefreshCw, Copy } from "lucide-react";
import * as uuid from 'uuid'
import { nanoid } from 'nanoid'
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
interface IDGeneratorProps {
label: string;
value: string;
onRegenerate: () => void;
}
const IDGenerator: FC<IDGeneratorProps> = ({ label, value, onRegenerate }) => {
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(value);
toast(`${label} has been copied to clipboard`);
} catch (err) {
toast.error("Copy failed");
}
};
return (
<div className="flex flex-col gap-2">
<label className="font-medium">{label}</label>
<div className="flex items-center gap-2">
<span className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm break-all max-w-[400px]">
{value}
</span>
<Button
size="icon"
variant="outline"
onClick={onRegenerate}
title="Regenerate"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
onClick={copyToClipboard}
title="Copy"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
);
};
const Tool: FC = () => {
const [uuidV1, setUuidV1] = useState(() => uuid.v1());
const [uuidV4, setUuidV4] = useState(() => uuid.v4());
const [uuidV6, setUuidV6] = useState(() => uuid.v6());
const [uuidV7, setUuidV7] = useState(() => uuid.v7());
const [nanoId, setNanoId] = useState(() => nanoid());
return (
<div className="flex flex-col gap-4">
<span className="text-sm text-muted-foreground">Click the refresh button to regenerate the corresponding ID</span>
<IDGenerator
label="UUID Version 1"
value={uuidV1}
onRegenerate={() => setUuidV1(uuid.v1())}
/>
<IDGenerator
label="UUID Version 4"
value={uuidV4}
onRegenerate={() => setUuidV4(uuid.v4())}
/>
<IDGenerator
label="UUID Version 6"
value={uuidV6}
onRegenerate={() => setUuidV6(uuid.v6())}
/>
<IDGenerator
label="UUID Version 7"
value={uuidV7}
onRegenerate={() => setUuidV7(uuid.v7())}
/>
<IDGenerator
label="Nano ID"
value={nanoId}
onRegenerate={() => setNanoId(nanoid())}
/>
</div>
);
};
export default Tool;

View File

@@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,12 +0,0 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -1,255 +0,0 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,21 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -1,26 +0,0 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,139 +0,0 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -1,726 +0,0 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,38 +0,0 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,59 +0,0 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,23 +0,0 @@
@layer base {
:root {
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
}

View File

@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,96 +0,0 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
interface UseSEOOptions {
title?: string;
description?: string;
baseUrl?: string;
}
/**
* SEO Hook - 动态更新页面 SEO 元数据
*
* @param options - SEO 配置选项
* @param options.title - 页面标题(可选)
* @param options.description - 页面描述(可选)
* @param options.baseUrl - 网站基础 URL默认为 https://litek.typist.cc
*
* @example
* ```tsx
* // 在组件中使用
* useSEO({
* title: 'UUID Generator',
* description: 'Free online UUID generator tool'
* });
* ```
*/
export const useSEO = (options: UseSEOOptions = {}) => {
const location = useLocation();
const {
title,
description,
baseUrl = 'https://litek.typist.cc'
} = options;
useEffect(() => {
// 构建当前页面的完整 URL
const canonicalUrl = `${baseUrl}${location.pathname}`;
// 更新或创建 canonical 链接
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement;
if (!canonical) {
canonical = document.createElement('link');
canonical.setAttribute('rel', 'canonical');
document.head.appendChild(canonical);
}
canonical.setAttribute('href', canonicalUrl);
// 更新页面标题
if (title) {
document.title = `${title} - Lite Kit`;
}
// 更新 meta description
if (description) {
let metaDescription = document.querySelector('meta[name="description"]') as HTMLMetaElement;
if (metaDescription) {
metaDescription.setAttribute('content', description);
}
}
// 更新 Open Graph URL
let ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement;
if (ogUrl) {
ogUrl.setAttribute('content', canonicalUrl);
}
// 更新 Open Graph Title
if (title) {
let ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement;
if (ogTitle) {
ogTitle.setAttribute('content', `${title} - Lite Kit`);
}
// 更新 Twitter Card Title
let twitterTitle = document.querySelector('meta[name="twitter:title"]') as HTMLMetaElement;
if (twitterTitle) {
twitterTitle.setAttribute('content', `${title} - Lite Kit`);
}
}
// 更新 Open Graph Description
if (description) {
let ogDescription = document.querySelector('meta[property="og:description"]') as HTMLMetaElement;
if (ogDescription) {
ogDescription.setAttribute('content', description);
}
// 更新 Twitter Card Description
let twitterDescription = document.querySelector('meta[name="twitter:description"]') as HTMLMetaElement;
if (twitterDescription) {
twitterDescription.setAttribute('content', description);
}
}
}, [location.pathname, title, description, baseUrl]);
};

View File

@@ -117,51 +117,4 @@
body {
@apply bg-background text-foreground;
}
}
/* 自定义滚动条样式 */
* {
scrollbar-width: thin;
scrollbar-color: oklch(0.551 0.027 264.364 / 0.3) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb {
background: oklch(0.551 0.027 264.364 / 0.3);
border-radius: 4px;
transition: background 0.2s ease;
}
*::-webkit-scrollbar-thumb:hover {
background: oklch(0.551 0.027 264.364 / 0.5);
}
*::-webkit-scrollbar-thumb:active {
background: oklch(0.551 0.027 264.364 / 0.6);
}
/* 深色模式下的滚动条 */
.dark *::-webkit-scrollbar-thumb {
background: oklch(0.707 0.022 261.325 / 0.4);
}
.dark *::-webkit-scrollbar-thumb:hover {
background: oklch(0.707 0.022 261.325 / 0.6);
}
.dark *::-webkit-scrollbar-thumb:active {
background: oklch(0.707 0.022 261.325 / 0.7);
}
.dark * {
scrollbar-color: oklch(0.707 0.022 261.325 / 0.4) transparent;
}

View File

@@ -1,31 +0,0 @@
import type { FC } from "react"
import { Outlet } from "react-router-dom";
import { ThemeProvider } from "@/components/theme/provider"
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "@/components/sidebar";
import { ModeToggle } from "@/components/theme/toggle";
import { useSEO } from "@/hooks/use-seo";
export const Layout: FC = () => {
// 使用 SEO hook 自动更新 canonical URL 和其他 SEO 元数据
useSEO();
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<SidebarProvider>
<AppSidebar />
<div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden">
<nav className="flex items-center justify-between">
<SidebarTrigger className="size-10" />
<ModeToggle />
</nav>
<main className="flex-1 overflow-auto p-4 overflow-hidden">
<Outlet />
</main>
</div>
</SidebarProvider>
</ThemeProvider>
);
};

View File

@@ -1,33 +1,9 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from '@/components/ui/sonner'
import './index.css'
import { AppRouter } from './router'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AppRouter />
<Toaster />
{null}
</StrictMode>
)
// 注册 Service Worker
if ('serviceWorker' in navigator && import.meta.env.PROD) {
import('workbox-window').then(({ Workbox }) => {
const wb = new Workbox('/sw.js')
wb.addEventListener('installed', (event) => {
if (event.isUpdate) {
console.log('New service worker installed, reloading page...')
window.location.reload()
}
})
wb.register()
}).catch((error) => {
console.error('Failed to register service worker:', error)
})
}

View File

@@ -1,77 +0,0 @@
import { Suspense, createElement } from "react";
import {
createBrowserRouter,
redirect,
RouterProvider,
type RouteObject,
} from "react-router-dom";
import { tools, type Tool } from "@/components/tool";
import { Layout } from "./layout";
// 加载中的占位组件
const LoadingFallback = () => (
<div className="flex items-center justify-center h-full">
<div className="text-center flex flex-col items-center gap-3">
<div className="relative">
<div className="h-12 w-12 rounded-full border-4 border-muted"></div>
<div className="absolute top-0 left-0 h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div>
</div>
<p className="text-sm text-muted-foreground font-medium">Loading...</p>
</div>
</div>
);
const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
return tools.map((tool) => {
const route: RouteObject = {
path: tool.path,
};
if (tool.component) {
// 使用 Suspense 包裹懒加载组件
route.element = (
<Suspense fallback={<LoadingFallback />}>
{createElement(tool.component)}
</Suspense>
);
}
if (tool.children && tool.children.length > 0) {
route.children = buildToolRoutes(tool.children);
}
return route;
});
};
// 路由配置
const router = createBrowserRouter([
{
path: "",
element: <Layout />,
children: [
{
path: "tool",
children: [
...buildToolRoutes(tools),
{
index: true,
loader: () => redirect("/tool/uuid"),
},
],
},
],
},
{
index: true,
loader: () => redirect("/tool"),
},
{
path: "*",
loader: () => redirect("/tool"),
},
]);
// 路由提供者组件
export const AppRouter = () => <RouterProvider router={router} />;

3
src/vite-env.d.ts vendored
View File

@@ -1,3 +0,0 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

View File

@@ -2,102 +2,13 @@ import path from "path"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from "@tailwindcss/vite"
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['lite.svg', 'robots.txt', 'sitemap.xml'],
manifest: {
name: 'Lite Kit - Lightweight Online Tools',
short_name: 'Lite Kit',
description: 'Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more',
theme_color: '#000000',
background_color: '#000000',
display: 'standalone',
icons: [
{
src: '/lite.svg',
type: 'image/svg+xml',
sizes: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/ipinfo\.io\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: 'ipinfo-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 // 延长到 1 小时
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
}
})
],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
// React 核心拆分得更细
if (id.includes('node_modules/react/') && !id.includes('node_modules/react-dom')) {
return 'react-core';
}
if (id.includes('node_modules/react-dom/')) {
return 'react-dom';
}
if (id.includes('node_modules/react-router-dom')) {
return 'react-router';
}
// Radix UI组件
if (id.includes('node_modules/@radix-ui')) {
return 'ui-vendor';
}
// 图标库
if (id.includes('node_modules/lucide-react')) {
return 'icons';
}
// 其他工具库
if (id.includes('node_modules/')) {
return 'vendor';
}
},
},
},
// 启用更激进的压缩
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log'],
// 移除未使用的代码
unused: true,
// 移除死代码
dead_code: true,
},
},
chunkSizeWarningLimit: 500,
},
})