Compare commits
6 Commits
v0.0.11
...
v0.0.1-sna
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82e10b72c7 | ||
|
|
1219deea89 | ||
|
|
b5b9d94119 | ||
|
|
d5959ca3c3 | ||
| 185ce1fac1 | |||
|
|
c11bb6d637 |
@@ -36,22 +36,13 @@ jobs:
|
|||||||
type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }}
|
type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }}
|
||||||
|
|
||||||
- name: 构建并推送 Docker 镜像
|
- name: 构建并推送 Docker 镜像
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache
|
cache-from: type=local,src=/cache
|
||||||
cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max
|
cache-to: type=local,dest=/cache,mode=max
|
||||||
# platforms: linux/amd64,linux/arm64
|
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 }}"}'
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
# 安装 pnpm
|
# 安装 pnpm
|
||||||
RUN npm install -g pnpm
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -11,8 +11,7 @@ WORKDIR /app
|
|||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
RUN pnpm install --frozen-lockfile
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# 复制源代码
|
# 复制源代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -1,37 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "litek",
|
"name": "litek",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.11",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"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"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.548.0",
|
"lucide-react": "^0.548.0",
|
||||||
"nanoid": "^5.1.6",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.4",
|
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16"
|
||||||
"uuid": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|||||||
869
pnpm-lock.yaml
generated
869
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
|||||||
import type { ReactNode } from "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";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { ModeToggle } from "@/components/theme/toggle";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
export const AppSidebar = () => {
|
|
||||||
// 递归构建完整路径
|
|
||||||
const buildFullPath = (pathSegments: string[]): string => {
|
|
||||||
return `/tool/${pathSegments.join("/")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 递归渲染菜单项
|
|
||||||
const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => {
|
|
||||||
const currentPaths = [...parentPaths, tool.path];
|
|
||||||
|
|
||||||
if (tool.children) {
|
|
||||||
// 有子菜单的项目
|
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
key={tool.name}
|
|
||||||
defaultOpen={false}
|
|
||||||
className="group/collapsible"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<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}>
|
|
||||||
{child.children ? (
|
|
||||||
renderMenuItem(child, currentPaths)
|
|
||||||
) : (
|
|
||||||
<SidebarMenuSubButton asChild>
|
|
||||||
<Link
|
|
||||||
to={buildFullPath([...currentPaths, child.path])}
|
|
||||||
title={child.description}
|
|
||||||
>
|
|
||||||
{child.icon}
|
|
||||||
<span>{child.name}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
)}
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 没有子菜单的项目
|
|
||||||
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">
|
|
||||||
<Button variant="link">
|
|
||||||
<a href="mailto:litek@mail.typist.cc">need more tools?</a>
|
|
||||||
</Button>
|
|
||||||
<ModeToggle />
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react'
|
|
||||||
|
|
||||||
import UUID from './uuid'
|
|
||||||
import JSON from './json'
|
|
||||||
import Base64 from './base64'
|
|
||||||
import { DNS, Ping, TCPing, SpeedTest, IPQuery } from './network'
|
|
||||||
|
|
||||||
export interface Tool {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
description: string;
|
|
||||||
component?: ReactNode;
|
|
||||||
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 />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { default as DNS } from './dns';
|
|
||||||
export { default as Ping } from './ping';
|
|
||||||
export { default as TCPing } from './tcping';
|
|
||||||
export { default as SpeedTest } from './speedtest';
|
|
||||||
export { default as IPQuery } from './ipquery';
|
|
||||||
|
|
||||||
@@ -1,312 +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 {
|
|
||||||
// 使用 ip-api.com (免费,功能较全)
|
|
||||||
const response = await fetch(`http://ip-api.com/json/${encodeURIComponent(ip.trim())}?fields=status,message,country,countryCode,region,city,lat,lon,timezone,isp,org,as,proxy,hosting,query`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const endTime = performance.now();
|
|
||||||
|
|
||||||
setQueryTime(endTime - startTime);
|
|
||||||
|
|
||||||
if (data.status === "fail") {
|
|
||||||
toast.error(data.message || "Query failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为统一格式
|
|
||||||
const ipData: IPInfo = {
|
|
||||||
ip: data.query,
|
|
||||||
city: data.city,
|
|
||||||
region: data.region,
|
|
||||||
country: data.country,
|
|
||||||
countryCode: data.countryCode,
|
|
||||||
loc: data.lat && data.lon ? `${data.lat},${data.lon}` : undefined,
|
|
||||||
timezone: data.timezone,
|
|
||||||
isp: data.isp,
|
|
||||||
org: data.org,
|
|
||||||
as: data.as,
|
|
||||||
proxy: data.proxy,
|
|
||||||
hosting: data.hosting,
|
|
||||||
};
|
|
||||||
|
|
||||||
setIpInfo(ipData);
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (ipInfo.proxy || ipInfo.hosting) {
|
|
||||||
return {
|
|
||||||
level: "High",
|
|
||||||
color: "text-red-500",
|
|
||||||
reasons: [
|
|
||||||
ipInfo.proxy && "Proxy/VPN detected",
|
|
||||||
ipInfo.hosting && "Hosting/Datacenter IP",
|
|
||||||
].filter(Boolean),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { type FC } from "react";
|
|
||||||
|
|
||||||
import * as uuid from 'uuid'
|
|
||||||
import { nanoid } from 'nanoid'
|
|
||||||
|
|
||||||
const Tool: FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="text-sm text-muted-foreground">Refresh the page to generate new UUID</span>
|
|
||||||
<label>UUID Version 1</label>
|
|
||||||
<span>{uuid.v1()}</span>
|
|
||||||
<label>UUID Version 4</label>
|
|
||||||
<span>{uuid.v4()}</span>
|
|
||||||
<label>UUID Version 6</label>
|
|
||||||
<span>{uuid.v6()}</span>
|
|
||||||
<label>UUID Version 7</label>
|
|
||||||
<span>{uuid.v7()}</span>
|
|
||||||
<label>Nano ID</label>
|
|
||||||
<span>{nanoid()}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Tool;
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -117,51 +117,4 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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;
|
|
||||||
}
|
}
|
||||||
@@ -1,23 +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";
|
|
||||||
|
|
||||||
export const Layout: FC = () => (
|
|
||||||
<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" />
|
|
||||||
<div role="actions" />
|
|
||||||
</nav>
|
|
||||||
<main className="flex-1 overflow-auto p-4 overflow-hidden">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</SidebarProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
|
||||||
|
|
||||||
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { AppRouter } from './router'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppRouter />
|
{null}
|
||||||
<Toaster />
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import {
|
|
||||||
createBrowserRouter,
|
|
||||||
redirect,
|
|
||||||
RouterProvider,
|
|
||||||
type RouteObject,
|
|
||||||
} from "react-router-dom";
|
|
||||||
|
|
||||||
import { tools, type Tool } from "@/components/tool";
|
|
||||||
import { Layout } from "./layout";
|
|
||||||
|
|
||||||
const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
|
|
||||||
return tools.map((tool) => {
|
|
||||||
const route: RouteObject = {
|
|
||||||
path: tool.path,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tool.component) {
|
|
||||||
route.element = tool.component;
|
|
||||||
}
|
|
||||||
|
|
||||||
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} />;
|
|
||||||
Reference in New Issue
Block a user