Compare commits
	
		
			4 Commits
		
	
	
		
			v0.0.11
			...
			v0.0.1-sna
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | b5b9d94119 | ||
|   | d5959ca3c3 | ||
| 185ce1fac1 | |||
|   | c11bb6d637 | 
| @@ -19,16 +19,16 @@ jobs: | |||||||
|       - name: 登录到 Gitea Container Registry |       - name: 登录到 Gitea Container Registry | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
|           registry: ${{ secrets.REGISTRY_ENDPOINT }} |           registry: ${{ gitea.server_url }} | ||||||
|           username: ${{ secrets.REGISTRY_USERNAME }} |           username: ${{ gitea.actor }} | ||||||
|           password: ${{ secrets.REGISTRY_PASSWORD }} |           password: ${{ gitea.token }} | ||||||
|        |        | ||||||
|       - name: 提取 Docker 元数据 |       - name: 提取 Docker 元数据 | ||||||
|         id: meta |         id: meta | ||||||
|         uses: docker/metadata-action@v5 |         uses: docker/metadata-action@v5 | ||||||
|         with: |         with: | ||||||
|           images: | |           images: | | ||||||
|             ${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek |             ${{ gitea.server_url }}/${{ gitea.repository_owner }}/litek | ||||||
|           tags: | |           tags: | | ||||||
|             type=semver,pattern={{version}} |             type=semver,pattern={{version}} | ||||||
|             type=semver,pattern={{major}}.{{minor}} |             type=semver,pattern={{major}}.{{minor}} | ||||||
| @@ -36,22 +36,13 @@ jobs: | |||||||
|             type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }} |             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=gha | ||||||
|           cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max |           cache-to: type=gha,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 |  | ||||||
| } |  | ||||||
| @@ -118,50 +118,3 @@ | |||||||
|     @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