Compare commits
	
		
			25 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 24c154a759 | ||
|   | edf87370d9 | ||
|   | ae0f9447ea | ||
|   | 972b6c7f22 | ||
|   | be56d896ca | ||
| 3b31ce9ddf | |||
|   | b97c746d36 | ||
| 9793d96def | |||
|   | a6d9c23179 | ||
|   | 28a86dcbff | ||
| 7cd826b052 | |||
|   | da20e34dc9 | ||
|   | a43b5a96bb | ||
|   | 48aaa262c1 | ||
| 660839d854 | |||
|   | 6d23d601a8 | ||
|   | 7405f2cb88 | ||
|   | d415615ad7 | ||
|   | 6cd05ca4b4 | ||
| 97b2093b86 | |||
|   | c0aa618dfa | ||
|   | 16aa543431 | ||
| 24594deecc | |||
| 0c54293312 | |||
|   | f4383d195f | 
| @@ -36,14 +36,22 @@ 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=local,src=/cache |           cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache | ||||||
|           cache-to: type=local,dest=/cache,mode=max |           cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max | ||||||
|           # platforms: linux/amd64,linux/arm64 |           # platforms: linux/amd64,linux/arm64 | ||||||
|           platforms: linux/amd64 |           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 }}"}' | ||||||
|  | 
 | ||||||
							
								
								
									
										82
									
								
								.gitea/workflows/deploy.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								.gitea/workflows/deploy.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | |||||||
|  | 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 | ||||||
|  |  | ||||||
| @@ -11,7 +11,8 @@ WORKDIR /app | |||||||
| COPY package.json pnpm-lock.yaml ./ | COPY package.json pnpm-lock.yaml ./ | ||||||
|  |  | ||||||
| # 安装依赖 | # 安装依赖 | ||||||
| RUN pnpm install --frozen-lockfile | RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ | ||||||
|  |     pnpm install --frozen-lockfile | ||||||
|  |  | ||||||
| # 复制源代码 | # 复制源代码 | ||||||
| COPY . . | COPY . . | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,24 +1,37 @@ | |||||||
| { | { | ||||||
|   "name": "litek", |   "name": "litek", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "version": "0.0.0", |   "version": "0.0.11", | ||||||
|   "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
											
										
									
								
							
							
								
								
									
										98
									
								
								src/components/sidebar/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/components/sidebar/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | |||||||
|  | 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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										73
									
								
								src/components/theme/provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/components/theme/provider.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | 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 | ||||||
|  | } | ||||||
							
								
								
									
										38
									
								
								src/components/theme/toggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/theme/toggle.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | 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> | ||||||
|  |   ) | ||||||
|  | } | ||||||
							
								
								
									
										70
									
								
								src/components/tool/base64.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/components/tool/base64.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | |||||||
|  | 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; | ||||||
|  |  | ||||||
							
								
								
									
										83
									
								
								src/components/tool/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/components/tool/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | |||||||
|  | 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 />, | ||||||
|  |       }, | ||||||
|  |     ], | ||||||
|  |   }, | ||||||
|  | ]; | ||||||
							
								
								
									
										48
									
								
								src/components/tool/json.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/components/tool/json.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | |||||||
|  | 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; | ||||||
							
								
								
									
										200
									
								
								src/components/tool/network/dns.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/components/tool/network/dns.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | |||||||
|  | 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; | ||||||
|  |  | ||||||
							
								
								
									
										6
									
								
								src/components/tool/network/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/components/tool/network/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | 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'; | ||||||
|  |  | ||||||
							
								
								
									
										312
									
								
								src/components/tool/network/ipquery.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										312
									
								
								src/components/tool/network/ipquery.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,312 @@ | |||||||
|  | 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; | ||||||
|  |  | ||||||
							
								
								
									
										278
									
								
								src/components/tool/network/ping.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								src/components/tool/network/ping.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,278 @@ | |||||||
|  | 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; | ||||||
|  |  | ||||||
							
								
								
									
										355
									
								
								src/components/tool/network/speedtest.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										355
									
								
								src/components/tool/network/speedtest.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,355 @@ | |||||||
|  | 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; | ||||||
|  |  | ||||||
							
								
								
									
										323
									
								
								src/components/tool/network/tcping.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										323
									
								
								src/components/tool/network/tcping.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,323 @@ | |||||||
|  | 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; | ||||||
|  |  | ||||||
							
								
								
									
										24
									
								
								src/components/tool/uuid.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/tool/uuid.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | 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; | ||||||
							
								
								
									
										66
									
								
								src/components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | |||||||
|  | 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 } | ||||||
							
								
								
									
										12
									
								
								src/components/ui/collapsible.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/components/ui/collapsible.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | "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 } | ||||||
|  |  | ||||||
							
								
								
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,255 @@ | |||||||
|  | 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, | ||||||
|  | } | ||||||
							
								
								
									
										21
									
								
								src/components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | 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 } | ||||||
							
								
								
									
										26
									
								
								src/components/ui/separator.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/components/ui/separator.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | 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 } | ||||||
							
								
								
									
										139
									
								
								src/components/ui/sheet.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/components/ui/sheet.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | |||||||
|  | "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, | ||||||
|  | } | ||||||
							
								
								
									
										726
									
								
								src/components/ui/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										726
									
								
								src/components/ui/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,726 @@ | |||||||
|  | "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, | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								src/components/ui/skeleton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/components/ui/skeleton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | 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 } | ||||||
							
								
								
									
										38
									
								
								src/components/ui/sonner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/ui/sonner.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | 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 } | ||||||
							
								
								
									
										18
									
								
								src/components/ui/textarea.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/components/ui/textarea.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | 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 } | ||||||
							
								
								
									
										59
									
								
								src/components/ui/tooltip.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/components/ui/tooltip.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | 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 } | ||||||
							
								
								
									
										23
									
								
								src/globals.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/globals.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | @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); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								src/hooks/use-mobile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/hooks/use-mobile.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | 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,3 +118,50 @@ | |||||||
|     @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; | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								src/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | 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,9 +1,15 @@ | |||||||
| 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> | ||||||
|     {null} |     <AppRouter /> | ||||||
|  |     <Toaster /> | ||||||
|   </StrictMode> |   </StrictMode> | ||||||
| ) | ) | ||||||
|   | |||||||
							
								
								
									
										58
									
								
								src/router.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/router.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | |||||||
|  | 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