Compare commits
	
		
			42 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ea62204d8e | ||
|   | 3bafcbafed | ||
|   | 1a00f27c6a | ||
|   | 1bcbd3e37d | ||
|   | e88770dd3f | ||
|   | 5cef15c032 | ||
|   | 970d1ac3ed | ||
|   | 0d6334592d | ||
|   | c501bb7dd4 | ||
|   | d24389af66 | ||
| 97f38b44f5 | |||
|   | 62350492e9 | ||
|   | 9540c2b550 | ||
|   | 006f3d4dbb | ||
|   | ff8d497f97 | ||
|   | 9c2799f7d5 | ||
|   | dd70f9d886 | ||
|   | 35cccf6a8f | ||
| c5616600fa | |||
|   | 986708fbb4 | ||
|   | 6a1b68ed2c | ||
|   | 32970acf32 | ||
|   | 812bb8c248 | ||
|   | 09f9e6588f | ||
|   | 10a167febd | ||
|   | 91c0686a46 | ||
|   | 40bfde8e57 | ||
|   | b4ba7a2219 | ||
|   | 25e42e3af5 | ||
|   | 4398b53ea7 | ||
|   | 3e14bc652f | ||
|   | aca7e11835 | ||
|   | 782de6e38a | ||
|   | 7646830194 | ||
|   | 59f998e8e3 | ||
|   | 55207beff5 | ||
|   | e98a344b95 | ||
|   | d553c3e04c | ||
|   | e2da2758cc | ||
|   | 3ab70498e6 | ||
|   | a5ef1a1e70 | ||
|   | 297000f208 | 
							
								
								
									
										171
									
								
								SEO-README.md
									
									
									
									
									
								
							
							
						
						
									
										171
									
								
								SEO-README.md
									
									
									
									
									
								
							| @@ -1,171 +0,0 @@ | |||||||
| # SEO 优化说明 |  | ||||||
|  |  | ||||||
| 本项目已完成基础 SEO 优化,以下是已实施的改动。 |  | ||||||
|  |  | ||||||
| ## 已完成的优化项 |  | ||||||
|  |  | ||||||
| ### 1. HTML Meta 标签优化 ✅ |  | ||||||
|  |  | ||||||
| 在 `index.html` 中添加了完整的 SEO 元数据: |  | ||||||
|  |  | ||||||
| - **基础 Meta 标签** |  | ||||||
|   - title, description, keywords |  | ||||||
|   - author, theme-color |  | ||||||
|   - canonical URL |  | ||||||
|    |  | ||||||
| - **Open Graph 标签**(社交媒体分享优化) |  | ||||||
|   - og:type, og:url, og:title |  | ||||||
|   - og:description, og:image, og:site_name |  | ||||||
|    |  | ||||||
| - **Twitter Card 标签** |  | ||||||
|   - twitter:card, twitter:title |  | ||||||
|   - twitter:description, twitter:image |  | ||||||
|  |  | ||||||
| - **结构化数据**(JSON-LD) |  | ||||||
|   - Schema.org WebSite 类型标记 |  | ||||||
|   - 提升搜索引擎理解能力 |  | ||||||
|  |  | ||||||
| ### 2. SEO 配置文件 ✅ |  | ||||||
|  |  | ||||||
| - **`public/robots.txt`** - 搜索引擎爬虫规则 |  | ||||||
|   - 允许所有爬虫访问 |  | ||||||
|   - 指定 sitemap 位置 |  | ||||||
|    |  | ||||||
| - **`public/sitemap.xml`** - 站点地图(自动生成) |  | ||||||
|   - 包含所有工具页面 URL |  | ||||||
|   - 设置更新频率和优先级 |  | ||||||
|   - 通过 `scripts/generate-sitemap.ts` 自动生成 |  | ||||||
|    |  | ||||||
| - **`public/manifest.json`** - PWA 配置 |  | ||||||
|   - 支持渐进式 Web 应用 |  | ||||||
|   - 改善移动端体验 |  | ||||||
|  |  | ||||||
| ### 3. 构建流程优化 ✅ |  | ||||||
|  |  | ||||||
| - **`package.json`** |  | ||||||
|   - 构建时自动生成 sitemap |  | ||||||
|   - 添加 tsx 依赖用于运行生成脚本 |  | ||||||
|  |  | ||||||
| ## 文件清单 |  | ||||||
|  |  | ||||||
| ### 新增文件 |  | ||||||
| ``` |  | ||||||
| public/ |  | ||||||
| ├── robots.txt              # 爬虫规则 |  | ||||||
| ├── sitemap.xml            # 站点地图(自动生成) |  | ||||||
| └── manifest.json          # PWA 配置 |  | ||||||
|  |  | ||||||
| scripts/ |  | ||||||
| └── generate-sitemap.ts    # Sitemap 生成脚本 |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ### 修改文件 |  | ||||||
| ``` |  | ||||||
| index.html                 # 添加 SEO meta 标签和结构化数据 |  | ||||||
| package.json               # 添加 sitemap 生成命令 |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ## 使用说明 |  | ||||||
|  |  | ||||||
| ### 开发 |  | ||||||
| ```bash |  | ||||||
| pnpm install               # 安装依赖 |  | ||||||
| pnpm dev                   # 启动开发服务器 |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ### 构建 |  | ||||||
| ```bash |  | ||||||
| pnpm run build             # 构建项目(自动生成 sitemap) |  | ||||||
| pnpm run generate:sitemap  # 单独生成 sitemap |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ### 部署前检查 |  | ||||||
|  |  | ||||||
| ⚠️ **重要:部署前必须更新配置文件中的域名!** |  | ||||||
|  |  | ||||||
| 需要将以下文件中的 `https://litek.typist.cc` 替换为你的实际域名: |  | ||||||
|  |  | ||||||
| 1. **`index.html`** |  | ||||||
|    - canonical URL |  | ||||||
|    - Open Graph URL 和 image |  | ||||||
|    - Twitter Card image |  | ||||||
|    - Structured Data URL |  | ||||||
|  |  | ||||||
| 2. **`public/robots.txt`** |  | ||||||
|    - Sitemap URL |  | ||||||
|  |  | ||||||
| 3. **`scripts/generate-sitemap.ts`** |  | ||||||
|    - BASE_URL 常量 |  | ||||||
|  |  | ||||||
| 更新后重新构建: |  | ||||||
| ```bash |  | ||||||
| pnpm run build |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| ## SEO 验证 |  | ||||||
|  |  | ||||||
| ### 部署后验证 |  | ||||||
|  |  | ||||||
| 1. **提交 sitemap 到 Google Search Console** |  | ||||||
|    - 访问 https://search.google.com/search-console |  | ||||||
|    - 添加网站资源 |  | ||||||
|    - 提交 sitemap: `https://你的域名/sitemap.xml` |  | ||||||
|  |  | ||||||
| 2. **测试结构化数据** |  | ||||||
|    - 访问 https://validator.schema.org/ |  | ||||||
|    - 输入网站 URL |  | ||||||
|    - 检查是否有错误 |  | ||||||
|  |  | ||||||
| 3. **测试 Open Graph 预览** |  | ||||||
|    - Facebook: https://developers.facebook.com/tools/debug/ |  | ||||||
|    - Twitter: https://cards-dev.twitter.com/validator |  | ||||||
|  |  | ||||||
| 4. **性能测试** |  | ||||||
|    - Google PageSpeed Insights: https://pagespeed.web.dev/ |  | ||||||
|    - 目标分数: SEO > 95 |  | ||||||
|  |  | ||||||
| ## 可访问的 URL |  | ||||||
|  |  | ||||||
| 部署后,以下 URL 应该可以正常访问: |  | ||||||
|  |  | ||||||
| - `https://你的域名/` - 主页 |  | ||||||
| - `https://你的域名/robots.txt` - 爬虫规则 |  | ||||||
| - `https://你的域名/sitemap.xml` - 站点地图 |  | ||||||
| - `https://你的域名/manifest.json` - PWA 配置 |  | ||||||
| - `https://你的域名/tool/uuid` - UUID 工具 |  | ||||||
| - `https://你的域名/tool/json` - JSON 工具 |  | ||||||
| - `https://你的域名/tool/base64` - Base64 工具 |  | ||||||
| - `https://你的域名/tool/network/dns` - DNS 工具 |  | ||||||
| - ... 其他工具页面 |  | ||||||
|  |  | ||||||
| ## 添加新工具时更新 SEO |  | ||||||
|  |  | ||||||
| 当添加新工具时,需要更新 `scripts/generate-sitemap.ts`: |  | ||||||
|  |  | ||||||
| ```typescript |  | ||||||
| const tools = [ |  | ||||||
|   // 现有工具... |  | ||||||
|   { path: '/tool/你的新工具', priority: '0.9' }, |  | ||||||
| ]; |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| 然后重新构建项目。 |  | ||||||
|  |  | ||||||
| ## SEO 效果预期 |  | ||||||
|  |  | ||||||
| - **1-2 周**:搜索引擎开始索引页面 |  | ||||||
| - **1-2 月**:主要关键词开始有排名 |  | ||||||
| - **3-6 月**:稳定的搜索排名和自然流量增长 |  | ||||||
|  |  | ||||||
| ## 技术栈 |  | ||||||
|  |  | ||||||
| - React 19 + TypeScript |  | ||||||
| - Vite (Rolldown) |  | ||||||
| - React Router v7 |  | ||||||
| - Radix UI + Tailwind CSS 4 |  | ||||||
| - Nginx |  | ||||||
|  |  | ||||||
| ## 联系方式 |  | ||||||
|  |  | ||||||
| 需要更多工具或有建议?联系:litek@mail.typist.cc |  | ||||||
|  |  | ||||||
							
								
								
									
										14
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								index.html
									
									
									
									
									
								
							| @@ -13,7 +13,15 @@ | |||||||
|      |      | ||||||
|     <!-- Favicon --> |     <!-- Favicon --> | ||||||
|     <link rel="icon" type="image/svg+xml" href="/lite.svg" /> |     <link rel="icon" type="image/svg+xml" href="/lite.svg" /> | ||||||
|     <link rel="canonical" href="https://litek.typist.cc/" /> |      | ||||||
|  |     <!-- DNS Prefetch & Preconnect for external APIs --> | ||||||
|  |     <link rel="dns-prefetch" href="https://ipinfo.io"> | ||||||
|  |     <link rel="preconnect" href="https://ipinfo.io" crossorigin> | ||||||
|  |      | ||||||
|  |     <!-- Google Fonts --> | ||||||
|  |     <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||||
|  |     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||||
|  |     <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500&display=swap" rel="stylesheet"> | ||||||
|      |      | ||||||
|     <!-- Open Graph / Facebook --> |     <!-- Open Graph / Facebook --> | ||||||
|     <meta property="og:type" content="website" /> |     <meta property="og:type" content="website" /> | ||||||
| @@ -56,5 +64,9 @@ | |||||||
|   <body> |   <body> | ||||||
|     <div id="root"></div> |     <div id="root"></div> | ||||||
|     <script type="module" src="/src/main.tsx"></script> |     <script type="module" src="/src/main.tsx"></script> | ||||||
|  |  | ||||||
|  |     <!-- Cloudflare Web Analytics - Only in Production --> | ||||||
|  |     <!--CLOUDFLARE_ANALYTICS_PLACEHOLDER--> | ||||||
|  |     <!-- End Cloudflare Web Analytics --> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,11 +1,11 @@ | |||||||
| { | { | ||||||
|   "name": "litek", |   "name": "litek", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "version": "0.0.15", |   "version": "0.0.34", | ||||||
|   "type": "module", |   "type": "module", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "dev": "vite", |     "dev": "vite", | ||||||
|     "build": "npm run generate:sitemap && tsc -b && vite build", |     "build": "tsc -b && vite build", | ||||||
|     "generate:sitemap": "tsx scripts/generate-sitemap.ts", |     "generate:sitemap": "tsx scripts/generate-sitemap.ts", | ||||||
|     "lint": "eslint .", |     "lint": "eslint .", | ||||||
|     "preview": "vite preview", |     "preview": "vite preview", | ||||||
| @@ -17,12 +17,15 @@ | |||||||
|     "@radix-ui/react-collapsible": "^1.1.12", |     "@radix-ui/react-collapsible": "^1.1.12", | ||||||
|     "@radix-ui/react-dialog": "^1.1.15", |     "@radix-ui/react-dialog": "^1.1.15", | ||||||
|     "@radix-ui/react-dropdown-menu": "^2.1.16", |     "@radix-ui/react-dropdown-menu": "^2.1.16", | ||||||
|  |     "@radix-ui/react-label": "^2.1.7", | ||||||
|  |     "@radix-ui/react-popover": "^1.1.15", | ||||||
|     "@radix-ui/react-separator": "^1.1.7", |     "@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", |     "@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", | ||||||
|  |     "cmdk": "^1.1.1", | ||||||
|     "lucide-react": "^0.548.0", |     "lucide-react": "^0.548.0", | ||||||
|     "nanoid": "^5.1.6", |     "nanoid": "^5.1.6", | ||||||
|     "next-themes": "^0.4.6", |     "next-themes": "^0.4.6", | ||||||
| @@ -44,11 +47,14 @@ | |||||||
|     "eslint-plugin-react-hooks": "^5.2.0", |     "eslint-plugin-react-hooks": "^5.2.0", | ||||||
|     "eslint-plugin-react-refresh": "^0.4.22", |     "eslint-plugin-react-refresh": "^0.4.22", | ||||||
|     "globals": "^16.4.0", |     "globals": "^16.4.0", | ||||||
|  |     "terser": "^5.44.0", | ||||||
|     "tsx": "^4.19.2", |     "tsx": "^4.19.2", | ||||||
|     "tw-animate-css": "^1.4.0", |     "tw-animate-css": "^1.4.0", | ||||||
|     "typescript": "~5.9.3", |     "typescript": "~5.9.3", | ||||||
|     "typescript-eslint": "^8.45.0", |     "typescript-eslint": "^8.45.0", | ||||||
|     "vite": "npm:rolldown-vite@7.1.14" |     "vite": "npm:rolldown-vite@7.1.14", | ||||||
|  |     "vite-plugin-pwa": "^1.1.0", | ||||||
|  |     "workbox-window": "^7.3.0" | ||||||
|   }, |   }, | ||||||
|   "pnpm": { |   "pnpm": { | ||||||
|     "overrides": { |     "overrides": { | ||||||
|   | |||||||
							
								
								
									
										2896
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2896
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,11 +1,11 @@ | |||||||
| import type { ReactNode } from "react"; | import type { ReactNode } from "react"; | ||||||
|  | import { Link } from "react-router-dom"; | ||||||
|  | import { ChevronRight } from "lucide-react"; | ||||||
|  |  | ||||||
| import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar"; | import { 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 { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; | ||||||
| import { tools, type Tool } from "@/components/tool"; | 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 = () => { | export const AppSidebar = () => { | ||||||
|   // 递归构建完整路径 |   // 递归构建完整路径 | ||||||
| @@ -13,6 +13,46 @@ export const AppSidebar = () => { | |||||||
|     return `/tool/${pathSegments.join("/")}`; |     return `/tool/${pathSegments.join("/")}`; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   // 递归渲染子菜单内容 | ||||||
|  |   const renderSubMenuContent = (child: Tool, currentPaths: string[]): ReactNode => { | ||||||
|  |     if (child.children) { | ||||||
|  |       // 子菜单内的可折叠项 | ||||||
|  |       return ( | ||||||
|  |         <Collapsible defaultOpen={false} className="group/collapsible"> | ||||||
|  |           <CollapsibleTrigger asChild> | ||||||
|  |             <SidebarMenuSubButton> | ||||||
|  |               {child.icon} | ||||||
|  |               <span>{child.name}</span> | ||||||
|  |               <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> | ||||||
|  |             </SidebarMenuSubButton> | ||||||
|  |           </CollapsibleTrigger> | ||||||
|  |           <CollapsibleContent> | ||||||
|  |             <SidebarMenuSub> | ||||||
|  |               {child.children.map((subChild) => ( | ||||||
|  |                 <SidebarMenuSubItem key={subChild.name}> | ||||||
|  |                   {renderSubMenuContent(subChild, [...currentPaths, child.path])} | ||||||
|  |                 </SidebarMenuSubItem> | ||||||
|  |               ))} | ||||||
|  |             </SidebarMenuSub> | ||||||
|  |           </CollapsibleContent> | ||||||
|  |         </Collapsible> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 叶子节点 | ||||||
|  |     return ( | ||||||
|  |       <SidebarMenuSubButton asChild> | ||||||
|  |         <Link | ||||||
|  |           to={buildFullPath([...currentPaths, child.path])} | ||||||
|  |           title={child.description} | ||||||
|  |         > | ||||||
|  |           {child.icon} | ||||||
|  |           <span>{child.name}</span> | ||||||
|  |         </Link> | ||||||
|  |       </SidebarMenuSubButton> | ||||||
|  |     ); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   // 递归渲染菜单项 |   // 递归渲染菜单项 | ||||||
|   const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => { |   const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => { | ||||||
|     const currentPaths = [...parentPaths, tool.path]; |     const currentPaths = [...parentPaths, tool.path]; | ||||||
| @@ -20,12 +60,11 @@ export const AppSidebar = () => { | |||||||
|     if (tool.children) { |     if (tool.children) { | ||||||
|       // 有子菜单的项目 |       // 有子菜单的项目 | ||||||
|       return ( |       return ( | ||||||
|  |         <SidebarMenuItem key={tool.name}> | ||||||
|           <Collapsible |           <Collapsible | ||||||
|           key={tool.name} |  | ||||||
|             defaultOpen={false} |             defaultOpen={false} | ||||||
|             className="group/collapsible" |             className="group/collapsible" | ||||||
|           > |           > | ||||||
|           <SidebarMenuItem> |  | ||||||
|             <CollapsibleTrigger asChild> |             <CollapsibleTrigger asChild> | ||||||
|               <SidebarMenuButton tooltip={tool.description}> |               <SidebarMenuButton tooltip={tool.description}> | ||||||
|                 {tool.icon} |                 {tool.icon} | ||||||
| @@ -37,25 +76,13 @@ export const AppSidebar = () => { | |||||||
|               <SidebarMenuSub> |               <SidebarMenuSub> | ||||||
|                 {tool.children.map((child) => ( |                 {tool.children.map((child) => ( | ||||||
|                   <SidebarMenuSubItem key={child.name}> |                   <SidebarMenuSubItem key={child.name}> | ||||||
|                     {child.children ? ( |                     {renderSubMenuContent(child, currentPaths)} | ||||||
|                       renderMenuItem(child, currentPaths) |  | ||||||
|                     ) : ( |  | ||||||
|                       <SidebarMenuSubButton asChild> |  | ||||||
|                         <Link |  | ||||||
|                           to={buildFullPath([...currentPaths, child.path])} |  | ||||||
|                           title={child.description} |  | ||||||
|                         > |  | ||||||
|                           {child.icon} |  | ||||||
|                           <span>{child.name}</span> |  | ||||||
|                         </Link> |  | ||||||
|                       </SidebarMenuSubButton> |  | ||||||
|                     )} |  | ||||||
|                   </SidebarMenuSubItem> |                   </SidebarMenuSubItem> | ||||||
|                 ))} |                 ))} | ||||||
|               </SidebarMenuSub> |               </SidebarMenuSub> | ||||||
|             </CollapsibleContent> |             </CollapsibleContent> | ||||||
|           </SidebarMenuItem> |  | ||||||
|           </Collapsible> |           </Collapsible> | ||||||
|  |         </SidebarMenuItem> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -87,12 +114,7 @@ export const AppSidebar = () => { | |||||||
|           </SidebarGroupContent> |           </SidebarGroupContent> | ||||||
|         </SidebarGroup> |         </SidebarGroup> | ||||||
|       </SidebarContent> |       </SidebarContent> | ||||||
|       <SidebarFooter className="flex flex-row justify-between items-center gap-2"> |       <SidebarFooter className="flex flex-col gap-2" /> | ||||||
|         <Button variant="link"> |  | ||||||
|           <a href="mailto:litek@mail.typist.cc">need more tools?</a> |  | ||||||
|         </Button> |  | ||||||
|         <ModeToggle /> |  | ||||||
|       </SidebarFooter> |  | ||||||
|     </Sidebar> |     </Sidebar> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
							
								
								
									
										413
									
								
								src/components/tool/currency.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										413
									
								
								src/components/tool/currency.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,413 @@ | |||||||
|  | import { useState, useEffect, type FC } from "react"; | ||||||
|  | import { Input } from "@/components/ui/input"; | ||||||
|  | import { Label } from "@/components/ui/label"; | ||||||
|  | import { Button } from "@/components/ui/button"; | ||||||
|  | import { Badge } from "@/components/ui/badge"; | ||||||
|  | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||||||
|  | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; | ||||||
|  | import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; | ||||||
|  | import { toast } from "sonner"; | ||||||
|  | import { Check, X, Plus } from "lucide-react"; | ||||||
|  |  | ||||||
|  | interface Currency { | ||||||
|  |   code: string; | ||||||
|  |   name: string; | ||||||
|  |   symbol: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Available currencies (supported by Frankfurter API) | ||||||
|  | const AVAILABLE_CURRENCIES: Currency[] = [ | ||||||
|  |   { code: "USD", name: "US Dollar", symbol: "$" }, | ||||||
|  |   { code: "CNY", name: "Chinese Yuan", symbol: "¥" }, | ||||||
|  |   { code: "EUR", name: "Euro", symbol: "€" }, | ||||||
|  |   { code: "GBP", name: "British Pound", symbol: "£" }, | ||||||
|  |   { code: "JPY", name: "Japanese Yen", symbol: "¥" }, | ||||||
|  |   { code: "HKD", name: "Hong Kong Dollar", symbol: "HK$" }, | ||||||
|  |   { code: "AUD", name: "Australian Dollar", symbol: "A$" }, | ||||||
|  |   { code: "CAD", name: "Canadian Dollar", symbol: "C$" }, | ||||||
|  |   { code: "SGD", name: "Singapore Dollar", symbol: "S$" }, | ||||||
|  |   { code: "CHF", name: "Swiss Franc", symbol: "CHF" }, | ||||||
|  |   { code: "NZD", name: "New Zealand Dollar", symbol: "NZ$" }, | ||||||
|  |   { code: "KRW", name: "South Korean Won", symbol: "₩" }, | ||||||
|  |   { code: "THB", name: "Thai Baht", symbol: "฿" }, | ||||||
|  |   { code: "MYR", name: "Malaysian Ringgit", symbol: "RM" }, | ||||||
|  |   { code: "INR", name: "Indian Rupee", symbol: "₹" }, | ||||||
|  |   { code: "BRL", name: "Brazilian Real", symbol: "R$" }, | ||||||
|  |   { code: "MXN", name: "Mexican Peso", symbol: "MX$" }, | ||||||
|  |   { code: "SEK", name: "Swedish Krona", symbol: "kr" }, | ||||||
|  |   { code: "NOK", name: "Norwegian Krone", symbol: "kr" }, | ||||||
|  |   { code: "DKK", name: "Danish Krone", symbol: "kr" }, | ||||||
|  |   { code: "PLN", name: "Polish Złoty", symbol: "zł" }, | ||||||
|  |   { code: "TRY", name: "Turkish Lira", symbol: "₺" }, | ||||||
|  |   { code: "PHP", name: "Philippine Peso", symbol: "₱" }, | ||||||
|  |   { code: "IDR", name: "Indonesian Rupiah", symbol: "Rp" }, | ||||||
|  |   { code: "ILS", name: "Israeli New Shekel", symbol: "₪" }, | ||||||
|  |   { code: "CZK", name: "Czech Koruna", symbol: "Kč" }, | ||||||
|  |   { code: "RON", name: "Romanian Leu", symbol: "lei" }, | ||||||
|  |   { code: "HUF", name: "Hungarian Forint", symbol: "Ft" }, | ||||||
|  |   { code: "BGN", name: "Bulgarian Lev", symbol: "лв" }, | ||||||
|  |   { code: "ISK", name: "Icelandic Króna", symbol: "kr" }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | // Required currencies (cannot be removed) - USD first, CNY second | ||||||
|  | const REQUIRED_CURRENCIES = ["USD", "CNY"]; | ||||||
|  |  | ||||||
|  | const STORAGE_KEY = "selectedCurrencies"; | ||||||
|  |  | ||||||
|  | const Tool: FC = () => { | ||||||
|  |   const [amounts, setAmounts] = useState<Record<string, string>>({}); | ||||||
|  |   const [selectedCurrencies, setSelectedCurrencies] = useState<string[]>([]); | ||||||
|  |   const [rates, setRates] = useState<Record<string, number>>({}); | ||||||
|  |   const [loading, setLoading] = useState<boolean>(false); | ||||||
|  |   const [open, setOpen] = useState(false); | ||||||
|  |  | ||||||
|  |   // Load selected currencies from localStorage | ||||||
|  |   useEffect(() => { | ||||||
|  |     try { | ||||||
|  |       const saved = localStorage.getItem(STORAGE_KEY); | ||||||
|  |       if (saved) { | ||||||
|  |         const parsed = JSON.parse(saved); | ||||||
|  |         if (Array.isArray(parsed) && parsed.length >= 2) { | ||||||
|  |           setSelectedCurrencies(parsed); | ||||||
|  |           return; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } catch (error) { | ||||||
|  |       console.error("Failed to load saved currencies:", error); | ||||||
|  |     } | ||||||
|  |     // Default: USD and CNY (USD first) | ||||||
|  |     setSelectedCurrencies(["USD", "CNY"]); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   // Save selected currencies to localStorage | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (selectedCurrencies.length >= 2) { | ||||||
|  |       try { | ||||||
|  |         localStorage.setItem(STORAGE_KEY, JSON.stringify(selectedCurrencies)); | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error("Failed to save currencies:", error); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [selectedCurrencies]); | ||||||
|  |  | ||||||
|  |   // Fetch exchange rates (Service Worker handles caching) | ||||||
|  |   useEffect(() => { | ||||||
|  |     const fetchRates = async () => { | ||||||
|  |       try { | ||||||
|  |         setLoading(true); | ||||||
|  |         // Service Worker will handle caching with StaleWhileRevalidate strategy | ||||||
|  |         const response = await fetch("https://api.frankfurter.app/latest?base=USD"); | ||||||
|  |          | ||||||
|  |         if (!response.ok) { | ||||||
|  |           throw new Error(`HTTP error! status: ${response.status}`); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         const data = await response.json(); | ||||||
|  |         const allRates: Record<string, number> = { USD: 1, ...data.rates }; | ||||||
|  |          | ||||||
|  |         setRates(allRates); | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error("Failed to fetch rates:", error); | ||||||
|  |         if (error instanceof Error) { | ||||||
|  |           toast.error(`Failed to fetch exchange rates: ${error.message}`); | ||||||
|  |         } else { | ||||||
|  |           toast.error("Failed to fetch exchange rates"); | ||||||
|  |         } | ||||||
|  |       } finally { | ||||||
|  |         setLoading(false); | ||||||
|  |       } | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     fetchRates(); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   // Initialize amounts after rates are loaded | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (selectedCurrencies.length > 0 && Object.keys(rates).length > 0) { | ||||||
|  |       if (Object.keys(amounts).length === 0) { | ||||||
|  |         // Initial setup: 1 USD as base | ||||||
|  |         const initialAmounts: Record<string, string> = {}; | ||||||
|  |         const baseAmountInUSD = 1; // 1 USD | ||||||
|  |          | ||||||
|  |         selectedCurrencies.forEach(code => { | ||||||
|  |           if (rates[code]) { | ||||||
|  |             const convertedAmount = baseAmountInUSD * rates[code]; | ||||||
|  |             initialAmounts[code] = convertedAmount.toFixed(2); | ||||||
|  |           } else { | ||||||
|  |             initialAmounts[code] = ""; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |         setAmounts(initialAmounts); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [selectedCurrencies, rates, amounts]); | ||||||
|  |  | ||||||
|  |   // Auto-update amounts when rates change | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (Object.keys(rates).length > 0 && Object.keys(amounts).length > 0) { | ||||||
|  |       // Find first currency with value as base | ||||||
|  |       const baseCurrency = selectedCurrencies.find(code => amounts[code] && parseFloat(amounts[code]) > 0); | ||||||
|  |        | ||||||
|  |       if (baseCurrency && rates[baseCurrency]) { | ||||||
|  |         const baseValue = parseFloat(amounts[baseCurrency]); | ||||||
|  |         const amountInUSD = baseValue / rates[baseCurrency]; | ||||||
|  |          | ||||||
|  |         // Recalculate all currencies | ||||||
|  |         const updatedAmounts: Record<string, string> = {}; | ||||||
|  |         selectedCurrencies.forEach(code => { | ||||||
|  |           if (rates[code]) { | ||||||
|  |             const convertedAmount = amountInUSD * rates[code]; | ||||||
|  |             updatedAmounts[code] = convertedAmount.toFixed(2); | ||||||
|  |           } else { | ||||||
|  |             updatedAmounts[code] = amounts[code] || ""; | ||||||
|  |           } | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         // Only update if values changed | ||||||
|  |         const hasChanged = selectedCurrencies.some( | ||||||
|  |           code => updatedAmounts[code] !== amounts[code] | ||||||
|  |         ); | ||||||
|  |         if (hasChanged) { | ||||||
|  |           setAmounts(updatedAmounts); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||||
|  |   }, [rates]); | ||||||
|  |  | ||||||
|  |   // Handle amount input change | ||||||
|  |   const handleAmountChange = (currencyCode: string, value: string) => { | ||||||
|  |     // Only allow numbers and decimal point | ||||||
|  |     if (value !== "" && !/^\d*\.?\d*$/.test(value)) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!rates[currencyCode]) { | ||||||
|  |       setAmounts({ ...amounts, [currencyCode]: value }); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Clear all if empty | ||||||
|  |     if (value === "" || value === "0" || parseFloat(value) === 0) { | ||||||
|  |       const newAmounts: Record<string, string> = {}; | ||||||
|  |       selectedCurrencies.forEach(code => { | ||||||
|  |         newAmounts[code] = ""; | ||||||
|  |       }); | ||||||
|  |       setAmounts(newAmounts); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const inputValue = parseFloat(value); | ||||||
|  |      | ||||||
|  |     // Calculate other currencies | ||||||
|  |     const newAmounts: Record<string, string> = { [currencyCode]: value }; | ||||||
|  |      | ||||||
|  |     // Convert to USD first | ||||||
|  |     const amountInUSD = inputValue / rates[currencyCode]; | ||||||
|  |      | ||||||
|  |     // Convert to other currencies | ||||||
|  |     selectedCurrencies.forEach(code => { | ||||||
|  |       if (code !== currencyCode && rates[code]) { | ||||||
|  |         const convertedAmount = amountInUSD * rates[code]; | ||||||
|  |         newAmounts[code] = convertedAmount.toFixed(2); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     setAmounts(newAmounts); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Add currency | ||||||
|  |   const addCurrency = (currencyCode: string) => { | ||||||
|  |     if (!selectedCurrencies.includes(currencyCode)) { | ||||||
|  |       setSelectedCurrencies([...selectedCurrencies, currencyCode]); | ||||||
|  |        | ||||||
|  |       // Calculate initial amount for new currency | ||||||
|  |       if (rates[currencyCode] && Object.keys(amounts).length > 0) { | ||||||
|  |         const firstCurrency = selectedCurrencies[0]; | ||||||
|  |         if (firstCurrency && amounts[firstCurrency] && rates[firstCurrency]) { | ||||||
|  |           const firstAmount = parseFloat(amounts[firstCurrency]) || 0; | ||||||
|  |           const amountInUSD = firstAmount / rates[firstCurrency]; | ||||||
|  |           const newAmount = amountInUSD * rates[currencyCode]; | ||||||
|  |           setAmounts({ ...amounts, [currencyCode]: newAmount.toFixed(2) }); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |        | ||||||
|  |       toast.success(`Added ${currencyCode}`); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Remove currency | ||||||
|  |   const removeCurrency = (currencyCode: string) => { | ||||||
|  |     if (REQUIRED_CURRENCIES.includes(currencyCode)) { | ||||||
|  |       toast.error(`${currencyCode} is required and cannot be removed`); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     if (selectedCurrencies.length <= 2) { | ||||||
|  |       toast.error("At least two currencies are required"); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     setSelectedCurrencies(selectedCurrencies.filter((c) => c !== currencyCode)); | ||||||
|  |      | ||||||
|  |     // Remove corresponding amount | ||||||
|  |     const newAmounts = { ...amounts }; | ||||||
|  |     delete newAmounts[currencyCode]; | ||||||
|  |     setAmounts(newAmounts); | ||||||
|  |      | ||||||
|  |     toast.success(`Removed ${currencyCode}`); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Get currency info | ||||||
|  |   const getCurrency = (code: string): Currency | undefined => { | ||||||
|  |     return AVAILABLE_CURRENCIES.find((c) => c.code === code); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="flex flex-col gap-4 h-full"> | ||||||
|  |  | ||||||
|  |       {/* Currency selector */} | ||||||
|  |       <div className="flex flex-col gap-3"> | ||||||
|  |         <div className="flex items-center justify-between"> | ||||||
|  |           <Label>Select Currencies</Label> | ||||||
|  |           {/* Add currency button - fixed at top right */} | ||||||
|  |           <Popover open={open} onOpenChange={setOpen}> | ||||||
|  |             <PopoverTrigger asChild> | ||||||
|  |               <Button variant="outline" size="sm" className="gap-1"> | ||||||
|  |                 <Plus className="size-4" /> | ||||||
|  |                 Add Currency | ||||||
|  |               </Button> | ||||||
|  |             </PopoverTrigger> | ||||||
|  |             <PopoverContent className="w-[300px] p-0" align="end" side="bottom" sideOffset={5}> | ||||||
|  |               <Command> | ||||||
|  |                 <CommandInput placeholder="Search currencies..." /> | ||||||
|  |                 <CommandList> | ||||||
|  |                   <CommandEmpty>No currency found</CommandEmpty> | ||||||
|  |                   <CommandGroup> | ||||||
|  |                     {AVAILABLE_CURRENCIES.map((currency) => { | ||||||
|  |                       const isSelected = selectedCurrencies.includes(currency.code); | ||||||
|  |                       const isRequired = REQUIRED_CURRENCIES.includes(currency.code); | ||||||
|  |                       return ( | ||||||
|  |                         <CommandItem | ||||||
|  |                           key={currency.code} | ||||||
|  |                           value={`${currency.code} ${currency.name}`} | ||||||
|  |                           disabled={isRequired} | ||||||
|  |                           onSelect={() => { | ||||||
|  |                             if (isRequired) return; // Required currencies cannot be toggled | ||||||
|  |                             if (isSelected && selectedCurrencies.length > 2) { | ||||||
|  |                               removeCurrency(currency.code); | ||||||
|  |                             } else if (!isSelected) { | ||||||
|  |                               addCurrency(currency.code); | ||||||
|  |                             } | ||||||
|  |                           }} | ||||||
|  |                         > | ||||||
|  |                           <Check | ||||||
|  |                             className={`mr-2 size-4 flex-shrink-0 ${ | ||||||
|  |                               isSelected ? "opacity-100" : "opacity-0" | ||||||
|  |                             }`} | ||||||
|  |                           /> | ||||||
|  |                           <span className="inline-block w-8 text-right flex-shrink-0">{currency.symbol}</span> | ||||||
|  |                           <span className="ml-2">{currency.code}</span> | ||||||
|  |                         </CommandItem> | ||||||
|  |                       ); | ||||||
|  |                     })} | ||||||
|  |                   </CommandGroup> | ||||||
|  |                 </CommandList> | ||||||
|  |               </Command> | ||||||
|  |             </PopoverContent> | ||||||
|  |           </Popover> | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         {/* Selected currency badges */} | ||||||
|  |         <div className="flex flex-wrap gap-2"> | ||||||
|  |           {selectedCurrencies.map((code) => { | ||||||
|  |             const currency = getCurrency(code); | ||||||
|  |             const isRequired = REQUIRED_CURRENCIES.includes(code); | ||||||
|  |             return ( | ||||||
|  |               <Badge | ||||||
|  |                 key={code} | ||||||
|  |                 variant={isRequired ? "default" : "secondary"} | ||||||
|  |                 className={`relative gap-1 px-2.5 py-1 transition-all duration-200 ${ | ||||||
|  |                   !isRequired  | ||||||
|  |                     ? "cursor-pointer hover:bg-destructive hover:text-destructive-foreground hover:border-destructive group"  | ||||||
|  |                     : "" | ||||||
|  |                 }`} | ||||||
|  |                 onClick={() => { | ||||||
|  |                   if (!isRequired) { | ||||||
|  |                     removeCurrency(code); | ||||||
|  |                   } | ||||||
|  |                 }} | ||||||
|  |               > | ||||||
|  |                 <span className={!isRequired ? "group-hover:opacity-0 transition-opacity duration-200" : ""}> | ||||||
|  |                   {currency?.symbol} {code} | ||||||
|  |                 </span> | ||||||
|  |                 {!isRequired && ( | ||||||
|  |                   <X className="absolute inset-0 m-auto size-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" /> | ||||||
|  |                 )} | ||||||
|  |               </Badge> | ||||||
|  |             ); | ||||||
|  |           })} | ||||||
|  |         </div> | ||||||
|  |          | ||||||
|  |         <span className="text-xs text-muted-foreground"> | ||||||
|  |           {REQUIRED_CURRENCIES.map((c) => getCurrency(c)?.code).join(", ")} are required. At least two currencies needed. | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {/* Currency list */} | ||||||
|  |       {loading ? ( | ||||||
|  |         <div className="flex items-center justify-center py-8 text-muted-foreground"> | ||||||
|  |           Loading exchange rates... | ||||||
|  |         </div> | ||||||
|  |       ) : ( | ||||||
|  |         <div className="flex flex-col gap-3 flex-1 overflow-auto"> | ||||||
|  |           <div className="text-sm font-medium">Currency Amounts:</div> | ||||||
|  |           <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 auto-rows-min"> | ||||||
|  |             {selectedCurrencies.map((code) => { | ||||||
|  |               const currency = getCurrency(code); | ||||||
|  |               if (!currency) return null; | ||||||
|  |                | ||||||
|  |               return ( | ||||||
|  |                 <Card key={code} className="flex flex-col"> | ||||||
|  |                   <CardHeader className="pb-3"> | ||||||
|  |                     <CardTitle className="text-sm font-medium text-muted-foreground"> | ||||||
|  |                       <div className="leading-tight"> | ||||||
|  |                         <div>{currency.symbol} {currency.code}</div> | ||||||
|  |                         <div className="text-xs font-normal mt-1">{currency.name}</div> | ||||||
|  |                       </div> | ||||||
|  |                     </CardTitle> | ||||||
|  |                   </CardHeader> | ||||||
|  |                   <CardContent> | ||||||
|  |                     <Input | ||||||
|  |                       type="text" | ||||||
|  |                       placeholder="Enter amount" | ||||||
|  |                       value={amounts[code] || ""} | ||||||
|  |                       onChange={(e) => handleAmountChange(code, e.target.value)} | ||||||
|  |                       className="text-xl font-semibold" | ||||||
|  |                     /> | ||||||
|  |                   </CardContent> | ||||||
|  |                 </Card> | ||||||
|  |               ); | ||||||
|  |             })} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       {!loading && Object.keys(rates).length > 0 && ( | ||||||
|  |         <div className="text-xs text-muted-foreground"> | ||||||
|  |           Exchange rate data from:{" "} | ||||||
|  |           <a | ||||||
|  |             href="https://www.frankfurter.app/" | ||||||
|  |             target="_blank" | ||||||
|  |             rel="noopener noreferrer" | ||||||
|  |             className="underline hover:text-foreground transition-colors" | ||||||
|  |           > | ||||||
|  |             Frankfurter API | ||||||
|  |           </a> | ||||||
|  |           {" "}(Based on European Central Bank data) | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Tool; | ||||||
|  |  | ||||||
| @@ -1,17 +1,23 @@ | |||||||
| import type { ReactNode } from 'react'; | import { lazy, type ReactNode, type ComponentType } from 'react'; | ||||||
| import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react' | import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin, Coins } from 'lucide-react' | ||||||
|  |  | ||||||
| import UUID from './uuid' | // 懒加载工具组件 | ||||||
| import JSON from './json' | const UUID = lazy(() => import('./uuid')) | ||||||
| import Base64 from './base64' | const JSON = lazy(() => import('./json')) | ||||||
| import { DNS, Ping, TCPing, SpeedTest, IPQuery } from './network' | const Base64 = lazy(() => import('./base64')) | ||||||
|  | const Currency = lazy(() => import('./currency')) | ||||||
|  | const DNS = lazy(() => import('./network/dns')) | ||||||
|  | const Ping = lazy(() => import('./network/ping')) | ||||||
|  | const TCPing = lazy(() => import('./network/tcping')) | ||||||
|  | const SpeedTest = lazy(() => import('./network/speedtest')) | ||||||
|  | const IPQuery = lazy(() => import('./network/ipquery')) | ||||||
|  |  | ||||||
| export interface Tool { | export interface Tool { | ||||||
|   path: string; |   path: string; | ||||||
|   name: string; |   name: string; | ||||||
|   icon: ReactNode; |   icon: ReactNode; | ||||||
|   description: string; |   description: string; | ||||||
|   component?: ReactNode; |   component?: ComponentType; | ||||||
|   children?: Tool[]; |   children?: Tool[]; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -21,21 +27,28 @@ export const tools: Tool[] = [ | |||||||
|     name: "UUID Generator", |     name: "UUID Generator", | ||||||
|     description: "Generate a UUID", |     description: "Generate a UUID", | ||||||
|     icon: <Hash />, |     icon: <Hash />, | ||||||
|     component: <UUID />, |     component: UUID, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: "json", |     path: "json", | ||||||
|     name: "JSON Formatter", |     name: "JSON Formatter", | ||||||
|     description: "Format and validate JSON", |     description: "Format and validate JSON", | ||||||
|     icon: <FileJson />, |     icon: <FileJson />, | ||||||
|     component: <JSON />, |     component: JSON, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: "base64", |     path: "base64", | ||||||
|     name: "Base64 Encoder/Decoder", |     name: "Base64 Encoder/Decoder", | ||||||
|     description: "Encode and decode Base64", |     description: "Encode and decode Base64", | ||||||
|     icon: <Binary />, |     icon: <Binary />, | ||||||
|     component: <Base64 />, |     component: Base64, | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: "currency", | ||||||
|  |     name: "Currency Converter", | ||||||
|  |     description: "Real-time currency exchange rates", | ||||||
|  |     icon: <Coins />, | ||||||
|  |     component: Currency, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     path: "network", |     path: "network", | ||||||
| @@ -48,35 +61,35 @@ export const tools: Tool[] = [ | |||||||
|         name: "DNS Lookup", |         name: "DNS Lookup", | ||||||
|         description: "DNS query tool", |         description: "DNS query tool", | ||||||
|         icon: <Globe />, |         icon: <Globe />, | ||||||
|         component: <DNS />, |         component: DNS, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         path: "ping", |         path: "ping", | ||||||
|         name: "Ping", |         name: "Ping", | ||||||
|         description: "Ping test tool", |         description: "Ping test tool", | ||||||
|         icon: <Activity />, |         icon: <Activity />, | ||||||
|         component: <Ping />, |         component: Ping, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         path: "tcping", |         path: "tcping", | ||||||
|         name: "TCPing", |         name: "TCPing", | ||||||
|         description: "TCP port connectivity test", |         description: "TCP port connectivity test", | ||||||
|         icon: <Wifi />, |         icon: <Wifi />, | ||||||
|         component: <TCPing />, |         component: TCPing, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         path: "speedtest", |         path: "speedtest", | ||||||
|         name: "Speed Test", |         name: "Speed Test", | ||||||
|         description: "Website speed test", |         description: "Website speed test", | ||||||
|         icon: <Gauge />, |         icon: <Gauge />, | ||||||
|         component: <SpeedTest />, |         component: SpeedTest, | ||||||
|       }, |       }, | ||||||
|       { |       { | ||||||
|         path: "ipquery", |         path: "ipquery", | ||||||
|         name: "IP Query", |         name: "IP Query", | ||||||
|         description: "Query IP location, quality and risk info", |         description: "Query IP location, quality and risk info", | ||||||
|         icon: <MapPin />, |         icon: <MapPin />, | ||||||
|         component: <IPQuery />, |         component: IPQuery, | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -1,6 +0,0 @@ | |||||||
| export { default as DNS } from './dns'; |  | ||||||
| export { default as Ping } from './ping'; |  | ||||||
| export { default as TCPing } from './tcping'; |  | ||||||
| export { default as SpeedTest } from './speedtest'; |  | ||||||
| export { default as IPQuery } from './ipquery'; |  | ||||||
|  |  | ||||||
| @@ -1,22 +1,94 @@ | |||||||
| import { type FC } from "react"; | import { type FC, useState } from "react"; | ||||||
|  | import { RefreshCw, Copy } from "lucide-react"; | ||||||
| import * as uuid from 'uuid' | import * as uuid from 'uuid' | ||||||
| import { nanoid } from 'nanoid' | import { nanoid } from 'nanoid' | ||||||
|  | import { Button } from "@/components/ui/button"; | ||||||
|  | import { toast } from "sonner"; | ||||||
|  |  | ||||||
|  | interface IDGeneratorProps { | ||||||
|  |   label: string; | ||||||
|  |   value: string; | ||||||
|  |   onRegenerate: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const IDGenerator: FC<IDGeneratorProps> = ({ label, value, onRegenerate }) => { | ||||||
|  |   const copyToClipboard = async () => { | ||||||
|  |     try { | ||||||
|  |       await navigator.clipboard.writeText(value); | ||||||
|  |       toast(`${label} has been copied to clipboard`); | ||||||
|  |     } catch (err) { | ||||||
|  |       toast.error("Copy failed"); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="flex flex-col gap-2"> | ||||||
|  |       <label className="font-medium">{label}</label> | ||||||
|  |       <div className="flex items-center gap-2"> | ||||||
|  |         <span className="flex-1 px-3 py-2 bg-muted rounded-md font-mono text-sm break-all max-w-[400px]"> | ||||||
|  |           {value} | ||||||
|  |         </span> | ||||||
|  |         <Button | ||||||
|  |           size="icon" | ||||||
|  |           variant="outline" | ||||||
|  |           onClick={onRegenerate} | ||||||
|  |           title="Regenerate" | ||||||
|  |         > | ||||||
|  |           <RefreshCw className="h-4 w-4" /> | ||||||
|  |         </Button> | ||||||
|  |         <Button | ||||||
|  |           size="icon" | ||||||
|  |           variant="outline" | ||||||
|  |           onClick={copyToClipboard} | ||||||
|  |           title="Copy" | ||||||
|  |         > | ||||||
|  |           <Copy className="h-4 w-4" /> | ||||||
|  |         </Button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
| const Tool: FC = () => { | const Tool: FC = () => { | ||||||
|  |   const [uuidV1, setUuidV1] = useState(() => uuid.v1()); | ||||||
|  |   const [uuidV4, setUuidV4] = useState(() => uuid.v4()); | ||||||
|  |   const [uuidV6, setUuidV6] = useState(() => uuid.v6()); | ||||||
|  |   const [uuidV7, setUuidV7] = useState(() => uuid.v7()); | ||||||
|  |   const [nanoId, setNanoId] = useState(() => nanoid()); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <div className="flex flex-col gap-4"> |     <div className="flex flex-col gap-4"> | ||||||
|       <span className="text-sm text-muted-foreground">Refresh the page to generate new UUID</span> |       <span className="text-sm text-muted-foreground">Click the refresh button to regenerate the corresponding ID</span> | ||||||
|       <label>UUID Version 1</label> |        | ||||||
|       <span>{uuid.v1()}</span> |       <IDGenerator  | ||||||
|       <label>UUID Version 4</label> |         label="UUID Version 1"  | ||||||
|       <span>{uuid.v4()}</span> |         value={uuidV1}  | ||||||
|       <label>UUID Version 6</label> |         onRegenerate={() => setUuidV1(uuid.v1())}  | ||||||
|       <span>{uuid.v6()}</span> |       /> | ||||||
|       <label>UUID Version 7</label> |        | ||||||
|       <span>{uuid.v7()}</span> |       <IDGenerator  | ||||||
|       <label>Nano ID</label> |         label="UUID Version 4"  | ||||||
|       <span>{nanoid()}</span> |         value={uuidV4}  | ||||||
|  |         onRegenerate={() => setUuidV4(uuid.v4())}  | ||||||
|  |       /> | ||||||
|  |        | ||||||
|  |       <IDGenerator  | ||||||
|  |         label="UUID Version 6"  | ||||||
|  |         value={uuidV6}  | ||||||
|  |         onRegenerate={() => setUuidV6(uuid.v6())}  | ||||||
|  |       /> | ||||||
|  |        | ||||||
|  |       <IDGenerator  | ||||||
|  |         label="UUID Version 7"  | ||||||
|  |         value={uuidV7}  | ||||||
|  |         onRegenerate={() => setUuidV7(uuid.v7())}  | ||||||
|  |       /> | ||||||
|  |        | ||||||
|  |       <IDGenerator  | ||||||
|  |         label="Nano ID"  | ||||||
|  |         value={nanoId}  | ||||||
|  |         onRegenerate={() => setNanoId(nanoid())}  | ||||||
|  |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | import * as React from "react" | ||||||
|  | import { Slot } from "@radix-ui/react-slot" | ||||||
|  | import { cva, type VariantProps } from "class-variance-authority" | ||||||
|  |  | ||||||
|  | import { cn } from "@/lib/utils" | ||||||
|  |  | ||||||
|  | const badgeVariants = cva( | ||||||
|  |   "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", | ||||||
|  |   { | ||||||
|  |     variants: { | ||||||
|  |       variant: { | ||||||
|  |         default: | ||||||
|  |           "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", | ||||||
|  |         secondary: | ||||||
|  |           "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", | ||||||
|  |         destructive: | ||||||
|  |           "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", | ||||||
|  |         outline: | ||||||
|  |           "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     defaultVariants: { | ||||||
|  |       variant: "default", | ||||||
|  |     }, | ||||||
|  |   } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | function Badge({ | ||||||
|  |   className, | ||||||
|  |   variant, | ||||||
|  |   asChild = false, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<"span"> & | ||||||
|  |   VariantProps<typeof badgeVariants> & { asChild?: boolean }) { | ||||||
|  |   const Comp = asChild ? Slot : "span" | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <Comp | ||||||
|  |       data-slot="badge" | ||||||
|  |       className={cn(badgeVariants({ variant }), className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export { Badge, badgeVariants } | ||||||
							
								
								
									
										92
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | |||||||
|  | import * as React from "react" | ||||||
|  |  | ||||||
|  | import { cn } from "@/lib/utils" | ||||||
|  |  | ||||||
|  | function Card({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="card" | ||||||
|  |       className={cn( | ||||||
|  |         "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="card-header" | ||||||
|  |       className={cn( | ||||||
|  |         "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="card-title" | ||||||
|  |       className={cn("leading-none font-semibold", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="card-description" | ||||||
|  |       className={cn("text-muted-foreground text-sm", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CardAction({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="card-action" | ||||||
|  |       className={cn( | ||||||
|  |         "col-start-2 row-span-2 row-start-1 self-start justify-self-end", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CardContent({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="card-content" | ||||||
|  |       className={cn("px-6", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="card-footer" | ||||||
|  |       className={cn("flex items-center px-6 [.border-t]:pt-6", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export { | ||||||
|  |   Card, | ||||||
|  |   CardHeader, | ||||||
|  |   CardFooter, | ||||||
|  |   CardTitle, | ||||||
|  |   CardAction, | ||||||
|  |   CardDescription, | ||||||
|  |   CardContent, | ||||||
|  | } | ||||||
							
								
								
									
										184
									
								
								src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | |||||||
|  | "use client" | ||||||
|  |  | ||||||
|  | import * as React from "react" | ||||||
|  | import { Command as CommandPrimitive } from "cmdk" | ||||||
|  | import { SearchIcon } from "lucide-react" | ||||||
|  |  | ||||||
|  | import { cn } from "@/lib/utils" | ||||||
|  | import { | ||||||
|  |   Dialog, | ||||||
|  |   DialogContent, | ||||||
|  |   DialogDescription, | ||||||
|  |   DialogHeader, | ||||||
|  |   DialogTitle, | ||||||
|  | } from "@/components/ui/dialog" | ||||||
|  |  | ||||||
|  | function Command({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof CommandPrimitive>) { | ||||||
|  |   return ( | ||||||
|  |     <CommandPrimitive | ||||||
|  |       data-slot="command" | ||||||
|  |       className={cn( | ||||||
|  |         "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandDialog({ | ||||||
|  |   title = "Command Palette", | ||||||
|  |   description = "Search for a command to run...", | ||||||
|  |   children, | ||||||
|  |   className, | ||||||
|  |   showCloseButton = true, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof Dialog> & { | ||||||
|  |   title?: string | ||||||
|  |   description?: string | ||||||
|  |   className?: string | ||||||
|  |   showCloseButton?: boolean | ||||||
|  | }) { | ||||||
|  |   return ( | ||||||
|  |     <Dialog {...props}> | ||||||
|  |       <DialogHeader className="sr-only"> | ||||||
|  |         <DialogTitle>{title}</DialogTitle> | ||||||
|  |         <DialogDescription>{description}</DialogDescription> | ||||||
|  |       </DialogHeader> | ||||||
|  |       <DialogContent | ||||||
|  |         className={cn("overflow-hidden p-0", className)} | ||||||
|  |         showCloseButton={showCloseButton} | ||||||
|  |       > | ||||||
|  |         <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> | ||||||
|  |           {children} | ||||||
|  |         </Command> | ||||||
|  |       </DialogContent> | ||||||
|  |     </Dialog> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandInput({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof CommandPrimitive.Input>) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="command-input-wrapper" | ||||||
|  |       className="flex h-9 items-center gap-2 border-b px-3" | ||||||
|  |     > | ||||||
|  |       <SearchIcon className="size-4 shrink-0 opacity-50" /> | ||||||
|  |       <CommandPrimitive.Input | ||||||
|  |         data-slot="command-input" | ||||||
|  |         className={cn( | ||||||
|  |           "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", | ||||||
|  |           className | ||||||
|  |         )} | ||||||
|  |         {...props} | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandList({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof CommandPrimitive.List>) { | ||||||
|  |   return ( | ||||||
|  |     <CommandPrimitive.List | ||||||
|  |       data-slot="command-list" | ||||||
|  |       className={cn( | ||||||
|  |         "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandEmpty({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof CommandPrimitive.Empty>) { | ||||||
|  |   return ( | ||||||
|  |     <CommandPrimitive.Empty | ||||||
|  |       data-slot="command-empty" | ||||||
|  |       className="py-6 text-center text-sm" | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandGroup({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof CommandPrimitive.Group>) { | ||||||
|  |   return ( | ||||||
|  |     <CommandPrimitive.Group | ||||||
|  |       data-slot="command-group" | ||||||
|  |       className={cn( | ||||||
|  |         "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandSeparator({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof CommandPrimitive.Separator>) { | ||||||
|  |   return ( | ||||||
|  |     <CommandPrimitive.Separator | ||||||
|  |       data-slot="command-separator" | ||||||
|  |       className={cn("bg-border -mx-1 h-px", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandItem({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof CommandPrimitive.Item>) { | ||||||
|  |   return ( | ||||||
|  |     <CommandPrimitive.Item | ||||||
|  |       data-slot="command-item" | ||||||
|  |       className={cn( | ||||||
|  |         "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_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=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function CommandShortcut({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<"span">) { | ||||||
|  |   return ( | ||||||
|  |     <span | ||||||
|  |       data-slot="command-shortcut" | ||||||
|  |       className={cn( | ||||||
|  |         "text-muted-foreground ml-auto text-xs tracking-widest", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export { | ||||||
|  |   Command, | ||||||
|  |   CommandDialog, | ||||||
|  |   CommandInput, | ||||||
|  |   CommandList, | ||||||
|  |   CommandEmpty, | ||||||
|  |   CommandGroup, | ||||||
|  |   CommandItem, | ||||||
|  |   CommandShortcut, | ||||||
|  |   CommandSeparator, | ||||||
|  | } | ||||||
							
								
								
									
										141
									
								
								src/components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | |||||||
|  | import * as React from "react" | ||||||
|  | import * as DialogPrimitive from "@radix-ui/react-dialog" | ||||||
|  | import { XIcon } from "lucide-react" | ||||||
|  |  | ||||||
|  | import { cn } from "@/lib/utils" | ||||||
|  |  | ||||||
|  | function Dialog({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Root>) { | ||||||
|  |   return <DialogPrimitive.Root data-slot="dialog" {...props} /> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogTrigger({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { | ||||||
|  |   return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogPortal({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Portal>) { | ||||||
|  |   return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogClose({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Close>) { | ||||||
|  |   return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogOverlay({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { | ||||||
|  |   return ( | ||||||
|  |     <DialogPrimitive.Overlay | ||||||
|  |       data-slot="dialog-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 DialogContent({ | ||||||
|  |   className, | ||||||
|  |   children, | ||||||
|  |   showCloseButton = true, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Content> & { | ||||||
|  |   showCloseButton?: boolean | ||||||
|  | }) { | ||||||
|  |   return ( | ||||||
|  |     <DialogPortal data-slot="dialog-portal"> | ||||||
|  |       <DialogOverlay /> | ||||||
|  |       <DialogPrimitive.Content | ||||||
|  |         data-slot="dialog-content" | ||||||
|  |         className={cn( | ||||||
|  |           "bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", | ||||||
|  |           className | ||||||
|  |         )} | ||||||
|  |         {...props} | ||||||
|  |       > | ||||||
|  |         {children} | ||||||
|  |         {showCloseButton && ( | ||||||
|  |           <DialogPrimitive.Close | ||||||
|  |             data-slot="dialog-close" | ||||||
|  |             className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" | ||||||
|  |           > | ||||||
|  |             <XIcon /> | ||||||
|  |             <span className="sr-only">Close</span> | ||||||
|  |           </DialogPrimitive.Close> | ||||||
|  |         )} | ||||||
|  |       </DialogPrimitive.Content> | ||||||
|  |     </DialogPortal> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="dialog-header" | ||||||
|  |       className={cn("flex flex-col gap-2 text-center sm:text-left", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { | ||||||
|  |   return ( | ||||||
|  |     <div | ||||||
|  |       data-slot="dialog-footer" | ||||||
|  |       className={cn( | ||||||
|  |         "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogTitle({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Title>) { | ||||||
|  |   return ( | ||||||
|  |     <DialogPrimitive.Title | ||||||
|  |       data-slot="dialog-title" | ||||||
|  |       className={cn("text-lg leading-none font-semibold", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function DialogDescription({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof DialogPrimitive.Description>) { | ||||||
|  |   return ( | ||||||
|  |     <DialogPrimitive.Description | ||||||
|  |       data-slot="dialog-description" | ||||||
|  |       className={cn("text-muted-foreground text-sm", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export { | ||||||
|  |   Dialog, | ||||||
|  |   DialogClose, | ||||||
|  |   DialogContent, | ||||||
|  |   DialogDescription, | ||||||
|  |   DialogFooter, | ||||||
|  |   DialogHeader, | ||||||
|  |   DialogOverlay, | ||||||
|  |   DialogPortal, | ||||||
|  |   DialogTitle, | ||||||
|  |   DialogTrigger, | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | "use client" | ||||||
|  |  | ||||||
|  | import * as React from "react" | ||||||
|  | import * as LabelPrimitive from "@radix-ui/react-label" | ||||||
|  |  | ||||||
|  | import { cn } from "@/lib/utils" | ||||||
|  |  | ||||||
|  | function Label({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof LabelPrimitive.Root>) { | ||||||
|  |   return ( | ||||||
|  |     <LabelPrimitive.Root | ||||||
|  |       data-slot="label" | ||||||
|  |       className={cn( | ||||||
|  |         "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export { Label } | ||||||
							
								
								
									
										46
									
								
								src/components/ui/popover.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/ui/popover.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | import * as React from "react" | ||||||
|  | import * as PopoverPrimitive from "@radix-ui/react-popover" | ||||||
|  |  | ||||||
|  | import { cn } from "@/lib/utils" | ||||||
|  |  | ||||||
|  | function Popover({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof PopoverPrimitive.Root>) { | ||||||
|  |   return <PopoverPrimitive.Root data-slot="popover" {...props} /> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function PopoverTrigger({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { | ||||||
|  |   return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function PopoverContent({ | ||||||
|  |   className, | ||||||
|  |   align = "center", | ||||||
|  |   sideOffset = 4, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof PopoverPrimitive.Content>) { | ||||||
|  |   return ( | ||||||
|  |     <PopoverPrimitive.Portal> | ||||||
|  |       <PopoverPrimitive.Content | ||||||
|  |         data-slot="popover-content" | ||||||
|  |         align={align} | ||||||
|  |         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 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", | ||||||
|  |           className | ||||||
|  |         )} | ||||||
|  |         {...props} | ||||||
|  |       /> | ||||||
|  |     </PopoverPrimitive.Portal> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function PopoverAnchor({ | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { | ||||||
|  |   return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| @layer base { |  | ||||||
|   :root { |  | ||||||
|     --sidebar: oklch(0.985 0 0); |  | ||||||
|     --sidebar-foreground: oklch(0.145 0 0); |  | ||||||
|     --sidebar-primary: oklch(0.205 0 0); |  | ||||||
|     --sidebar-primary-foreground: oklch(0.985 0 0); |  | ||||||
|     --sidebar-accent: oklch(0.97 0 0); |  | ||||||
|     --sidebar-accent-foreground: oklch(0.205 0 0); |  | ||||||
|     --sidebar-border: oklch(0.922 0 0); |  | ||||||
|     --sidebar-ring: oklch(0.708 0 0); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .dark { |  | ||||||
|     --sidebar: oklch(0.205 0 0); |  | ||||||
|     --sidebar-foreground: oklch(0.985 0 0); |  | ||||||
|     --sidebar-primary: oklch(0.488 0.243 264.376); |  | ||||||
|     --sidebar-primary-foreground: oklch(0.985 0 0); |  | ||||||
|     --sidebar-accent: oklch(0.269 0 0); |  | ||||||
|     --sidebar-accent-foreground: oklch(0.985 0 0); |  | ||||||
|     --sidebar-border: oklch(1 0 0 / 10%); |  | ||||||
|     --sidebar-ring: oklch(0.439 0 0); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										96
									
								
								src/hooks/use-seo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/hooks/use-seo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | import { useEffect } from 'react'; | ||||||
|  | import { useLocation } from 'react-router-dom'; | ||||||
|  |  | ||||||
|  | interface UseSEOOptions { | ||||||
|  |   title?: string; | ||||||
|  |   description?: string; | ||||||
|  |   baseUrl?: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * SEO Hook - 动态更新页面 SEO 元数据 | ||||||
|  |  *  | ||||||
|  |  * @param options - SEO 配置选项 | ||||||
|  |  * @param options.title - 页面标题(可选) | ||||||
|  |  * @param options.description - 页面描述(可选) | ||||||
|  |  * @param options.baseUrl - 网站基础 URL,默认为 https://litek.typist.cc | ||||||
|  |  *  | ||||||
|  |  * @example | ||||||
|  |  * ```tsx | ||||||
|  |  * // 在组件中使用 | ||||||
|  |  * useSEO({ | ||||||
|  |  *   title: 'UUID Generator', | ||||||
|  |  *   description: 'Free online UUID generator tool' | ||||||
|  |  * }); | ||||||
|  |  * ``` | ||||||
|  |  */ | ||||||
|  | export const useSEO = (options: UseSEOOptions = {}) => { | ||||||
|  |   const location = useLocation(); | ||||||
|  |   const {  | ||||||
|  |     title,  | ||||||
|  |     description,  | ||||||
|  |     baseUrl = 'https://litek.typist.cc'  | ||||||
|  |   } = options; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     // 构建当前页面的完整 URL | ||||||
|  |     const canonicalUrl = `${baseUrl}${location.pathname}`; | ||||||
|  |      | ||||||
|  |     // 更新或创建 canonical 链接 | ||||||
|  |     let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement; | ||||||
|  |     if (!canonical) { | ||||||
|  |       canonical = document.createElement('link'); | ||||||
|  |       canonical.setAttribute('rel', 'canonical'); | ||||||
|  |       document.head.appendChild(canonical); | ||||||
|  |     } | ||||||
|  |     canonical.setAttribute('href', canonicalUrl); | ||||||
|  |  | ||||||
|  |     // 更新页面标题 | ||||||
|  |     if (title) { | ||||||
|  |       document.title = `${title} - Lite Kit`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 更新 meta description | ||||||
|  |     if (description) { | ||||||
|  |       let metaDescription = document.querySelector('meta[name="description"]') as HTMLMetaElement; | ||||||
|  |       if (metaDescription) { | ||||||
|  |         metaDescription.setAttribute('content', description); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 更新 Open Graph URL | ||||||
|  |     let ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement; | ||||||
|  |     if (ogUrl) { | ||||||
|  |       ogUrl.setAttribute('content', canonicalUrl); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 更新 Open Graph Title | ||||||
|  |     if (title) { | ||||||
|  |       let ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement; | ||||||
|  |       if (ogTitle) { | ||||||
|  |         ogTitle.setAttribute('content', `${title} - Lite Kit`); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // 更新 Twitter Card Title | ||||||
|  |       let twitterTitle = document.querySelector('meta[name="twitter:title"]') as HTMLMetaElement; | ||||||
|  |       if (twitterTitle) { | ||||||
|  |         twitterTitle.setAttribute('content', `${title} - Lite Kit`); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 更新 Open Graph Description | ||||||
|  |     if (description) { | ||||||
|  |       let ogDescription = document.querySelector('meta[property="og:description"]') as HTMLMetaElement; | ||||||
|  |       if (ogDescription) { | ||||||
|  |         ogDescription.setAttribute('content', description); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // 更新 Twitter Card Description | ||||||
|  |       let twitterDescription = document.querySelector('meta[name="twitter:description"]') as HTMLMetaElement; | ||||||
|  |       if (twitterDescription) { | ||||||
|  |         twitterDescription.setAttribute('content', description); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [location.pathname, title, description, baseUrl]); | ||||||
|  | }; | ||||||
|  |  | ||||||
							
								
								
									
										111
									
								
								src/index.css
									
									
									
									
									
								
							
							
						
						
									
										111
									
								
								src/index.css
									
									
									
									
									
								
							| @@ -42,72 +42,72 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| :root { | :root { | ||||||
|   --radius: 0.625rem; |   --radius: 0.65rem; | ||||||
|   --background: oklch(1 0 0); |   --background: oklch(1 0 0); | ||||||
|   --foreground: oklch(0.13 0.028 261.692); |   --foreground: oklch(0.141 0.005 285.823); | ||||||
|   --card: oklch(1 0 0); |   --card: oklch(1 0 0); | ||||||
|   --card-foreground: oklch(0.13 0.028 261.692); |   --card-foreground: oklch(0.141 0.005 285.823); | ||||||
|   --popover: oklch(1 0 0); |   --popover: oklch(1 0 0); | ||||||
|   --popover-foreground: oklch(0.13 0.028 261.692); |   --popover-foreground: oklch(0.141 0.005 285.823); | ||||||
|   --primary: oklch(0.21 0.034 264.665); |   --primary: oklch(0.646 0.222 41.116); | ||||||
|   --primary-foreground: oklch(0.985 0.002 247.839); |   --primary-foreground: oklch(0.98 0.016 73.684); | ||||||
|   --secondary: oklch(0.967 0.003 264.542); |   --secondary: oklch(0.967 0.001 286.375); | ||||||
|   --secondary-foreground: oklch(0.21 0.034 264.665); |   --secondary-foreground: oklch(0.21 0.006 285.885); | ||||||
|   --muted: oklch(0.967 0.003 264.542); |   --muted: oklch(0.967 0.001 286.375); | ||||||
|   --muted-foreground: oklch(0.551 0.027 264.364); |   --muted-foreground: oklch(0.552 0.016 285.938); | ||||||
|   --accent: oklch(0.967 0.003 264.542); |   --accent: oklch(0.967 0.001 286.375); | ||||||
|   --accent-foreground: oklch(0.21 0.034 264.665); |   --accent-foreground: oklch(0.21 0.006 285.885); | ||||||
|   --destructive: oklch(0.577 0.245 27.325); |   --destructive: oklch(0.577 0.245 27.325); | ||||||
|   --border: oklch(0.928 0.006 264.531); |   --border: oklch(0.92 0.004 286.32); | ||||||
|   --input: oklch(0.928 0.006 264.531); |   --input: oklch(0.92 0.004 286.32); | ||||||
|   --ring: oklch(0.707 0.022 261.325); |   --ring: oklch(0.75 0.183 55.934); | ||||||
|   --chart-1: oklch(0.646 0.222 41.116); |   --chart-1: oklch(0.837 0.128 66.29); | ||||||
|   --chart-2: oklch(0.6 0.118 184.704); |   --chart-2: oklch(0.705 0.213 47.604); | ||||||
|   --chart-3: oklch(0.398 0.07 227.392); |   --chart-3: oklch(0.646 0.222 41.116); | ||||||
|   --chart-4: oklch(0.828 0.189 84.429); |   --chart-4: oklch(0.553 0.195 38.402); | ||||||
|   --chart-5: oklch(0.769 0.188 70.08); |   --chart-5: oklch(0.47 0.157 37.304); | ||||||
|   --sidebar: oklch(0.985 0.002 247.839); |   --sidebar: oklch(0.985 0 0); | ||||||
|   --sidebar-foreground: oklch(0.13 0.028 261.692); |   --sidebar-foreground: oklch(0.141 0.005 285.823); | ||||||
|   --sidebar-primary: oklch(0.21 0.034 264.665); |   --sidebar-primary: oklch(0.646 0.222 41.116); | ||||||
|   --sidebar-primary-foreground: oklch(0.985 0.002 247.839); |   --sidebar-primary-foreground: oklch(0.98 0.016 73.684); | ||||||
|   --sidebar-accent: oklch(0.967 0.003 264.542); |   --sidebar-accent: oklch(0.967 0.001 286.375); | ||||||
|   --sidebar-accent-foreground: oklch(0.21 0.034 264.665); |   --sidebar-accent-foreground: oklch(0.21 0.006 285.885); | ||||||
|   --sidebar-border: oklch(0.928 0.006 264.531); |   --sidebar-border: oklch(0.92 0.004 286.32); | ||||||
|   --sidebar-ring: oklch(0.707 0.022 261.325); |   --sidebar-ring: oklch(0.75 0.183 55.934); | ||||||
| } | } | ||||||
|  |  | ||||||
| .dark { | .dark { | ||||||
|   --background: oklch(0.13 0.028 261.692); |   --background: oklch(0.141 0.005 285.823); | ||||||
|   --foreground: oklch(0.985 0.002 247.839); |   --foreground: oklch(0.985 0 0); | ||||||
|   --card: oklch(0.21 0.034 264.665); |   --card: oklch(0.21 0.006 285.885); | ||||||
|   --card-foreground: oklch(0.985 0.002 247.839); |   --card-foreground: oklch(0.985 0 0); | ||||||
|   --popover: oklch(0.21 0.034 264.665); |   --popover: oklch(0.21 0.006 285.885); | ||||||
|   --popover-foreground: oklch(0.985 0.002 247.839); |   --popover-foreground: oklch(0.985 0 0); | ||||||
|   --primary: oklch(0.928 0.006 264.531); |   --primary: oklch(0.705 0.213 47.604); | ||||||
|   --primary-foreground: oklch(0.21 0.034 264.665); |   --primary-foreground: oklch(0.98 0.016 73.684); | ||||||
|   --secondary: oklch(0.278 0.033 256.848); |   --secondary: oklch(0.274 0.006 286.033); | ||||||
|   --secondary-foreground: oklch(0.985 0.002 247.839); |   --secondary-foreground: oklch(0.985 0 0); | ||||||
|   --muted: oklch(0.278 0.033 256.848); |   --muted: oklch(0.274 0.006 286.033); | ||||||
|   --muted-foreground: oklch(0.707 0.022 261.325); |   --muted-foreground: oklch(0.705 0.015 286.067); | ||||||
|   --accent: oklch(0.278 0.033 256.848); |   --accent: oklch(0.274 0.006 286.033); | ||||||
|   --accent-foreground: oklch(0.985 0.002 247.839); |   --accent-foreground: oklch(0.985 0 0); | ||||||
|   --destructive: oklch(0.704 0.191 22.216); |   --destructive: oklch(0.704 0.191 22.216); | ||||||
|   --border: oklch(1 0 0 / 10%); |   --border: oklch(1 0 0 / 10%); | ||||||
|   --input: oklch(1 0 0 / 15%); |   --input: oklch(1 0 0 / 15%); | ||||||
|   --ring: oklch(0.551 0.027 264.364); |   --ring: oklch(0.408 0.123 38.172); | ||||||
|   --chart-1: oklch(0.488 0.243 264.376); |   --chart-1: oklch(0.837 0.128 66.29); | ||||||
|   --chart-2: oklch(0.696 0.17 162.48); |   --chart-2: oklch(0.705 0.213 47.604); | ||||||
|   --chart-3: oklch(0.769 0.188 70.08); |   --chart-3: oklch(0.646 0.222 41.116); | ||||||
|   --chart-4: oklch(0.627 0.265 303.9); |   --chart-4: oklch(0.553 0.195 38.402); | ||||||
|   --chart-5: oklch(0.645 0.246 16.439); |   --chart-5: oklch(0.47 0.157 37.304); | ||||||
|   --sidebar: oklch(0.21 0.034 264.665); |   --sidebar: oklch(0.21 0.006 285.885); | ||||||
|   --sidebar-foreground: oklch(0.985 0.002 247.839); |   --sidebar-foreground: oklch(0.985 0 0); | ||||||
|   --sidebar-primary: oklch(0.488 0.243 264.376); |   --sidebar-primary: oklch(0.705 0.213 47.604); | ||||||
|   --sidebar-primary-foreground: oklch(0.985 0.002 247.839); |   --sidebar-primary-foreground: oklch(0.98 0.016 73.684); | ||||||
|   --sidebar-accent: oklch(0.278 0.033 256.848); |   --sidebar-accent: oklch(0.274 0.006 286.033); | ||||||
|   --sidebar-accent-foreground: oklch(0.985 0.002 247.839); |   --sidebar-accent-foreground: oklch(0.985 0 0); | ||||||
|   --sidebar-border: oklch(1 0 0 / 10%); |   --sidebar-border: oklch(1 0 0 / 10%); | ||||||
|   --sidebar-ring: oklch(0.551 0.027 264.364); |   --sidebar-ring: oklch(0.408 0.123 38.172); | ||||||
| } | } | ||||||
|  |  | ||||||
| @layer base { | @layer base { | ||||||
| @@ -116,6 +116,7 @@ | |||||||
|   } |   } | ||||||
|   body { |   body { | ||||||
|     @apply bg-background text-foreground; |     @apply bg-background text-foreground; | ||||||
|  |     font-family: 'Roboto Mono', 'Noto Sans SC', 'SF Mono', Consolas, monospace; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,15 +4,22 @@ import { Outlet } from "react-router-dom"; | |||||||
| import { ThemeProvider } from "@/components/theme/provider" | import { ThemeProvider } from "@/components/theme/provider" | ||||||
| import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" | import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" | ||||||
| import { AppSidebar } from "@/components/sidebar"; | import { AppSidebar } from "@/components/sidebar"; | ||||||
|  | import { ModeToggle } from "@/components/theme/toggle"; | ||||||
|  |  | ||||||
| export const Layout: FC = () => ( | import { useSEO } from "@/hooks/use-seo"; | ||||||
|  |  | ||||||
|  | export const Layout: FC = () => { | ||||||
|  |   // 使用 SEO hook 自动更新 canonical URL 和其他 SEO 元数据 | ||||||
|  |   useSEO(); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|     <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> |     <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> | ||||||
|       <SidebarProvider> |       <SidebarProvider> | ||||||
|         <AppSidebar /> |         <AppSidebar /> | ||||||
|         <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden"> |         <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden"> | ||||||
|           <nav className="flex items-center justify-between"> |           <nav className="flex items-center justify-between"> | ||||||
|             <SidebarTrigger className="size-10" /> |             <SidebarTrigger className="size-10" /> | ||||||
|           <div role="actions" /> |             <ModeToggle /> | ||||||
|           </nav> |           </nav> | ||||||
|           <main className="flex-1 overflow-auto p-4 overflow-hidden"> |           <main className="flex-1 overflow-auto p-4 overflow-hidden"> | ||||||
|             <Outlet /> |             <Outlet /> | ||||||
| @@ -21,3 +28,4 @@ export const Layout: FC = () => ( | |||||||
|       </SidebarProvider> |       </SidebarProvider> | ||||||
|     </ThemeProvider> |     </ThemeProvider> | ||||||
|   ); |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										41
									
								
								src/main.tsx
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								src/main.tsx
									
									
									
									
									
								
							| @@ -2,7 +2,7 @@ import { StrictMode } from 'react' | |||||||
| import { createRoot } from 'react-dom/client' | import { createRoot } from 'react-dom/client' | ||||||
|  |  | ||||||
| import { Toaster } from '@/components/ui/sonner' | import { Toaster } from '@/components/ui/sonner' | ||||||
|  | import { toast } from 'sonner' | ||||||
|  |  | ||||||
| import './index.css' | import './index.css' | ||||||
| import { AppRouter } from './router' | import { AppRouter } from './router' | ||||||
| @@ -13,3 +13,42 @@ createRoot(document.getElementById('root')!).render( | |||||||
|     <Toaster /> |     <Toaster /> | ||||||
|   </StrictMode> |   </StrictMode> | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | // 注册 Service Worker | ||||||
|  | if ('serviceWorker' in navigator && import.meta.env.PROD) { | ||||||
|  |   import('workbox-window').then(({ Workbox }) => { | ||||||
|  |     const wb = new Workbox('/sw.js') | ||||||
|  |      | ||||||
|  |     // 检测到新版本时,在后台下载完成后显示通知 | ||||||
|  |     wb.addEventListener('waiting', () => { | ||||||
|  |       // 显示更新通知,右上角弹窗 | ||||||
|  |       toast.info('found new version', { | ||||||
|  |         description: 'new content available, click update to get the latest version', | ||||||
|  |         duration: Infinity, // 持续显示,直到用户操作 | ||||||
|  |         action: { | ||||||
|  |           label: 'update now', | ||||||
|  |           onClick: () => { | ||||||
|  |             // 用户点击更新按钮 | ||||||
|  |             wb.messageSkipWaiting() | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         cancel: { | ||||||
|  |           label: 'later', | ||||||
|  |           onClick: () => { | ||||||
|  |             // 用户选择稍后更新,关闭通知 | ||||||
|  |             // 新版本会在下次手动刷新时自动激活 | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     // 当新的 Service Worker 接管页面时,刷新页面 | ||||||
|  |     wb.addEventListener('controlling', () => { | ||||||
|  |       window.location.reload() | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     wb.register() | ||||||
|  |   }).catch((error) => { | ||||||
|  |     console.error('Failed to register service worker:', error) | ||||||
|  |   }) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import { Suspense, createElement } from "react"; | ||||||
| import { | import { | ||||||
|   createBrowserRouter, |   createBrowserRouter, | ||||||
|   redirect, |   redirect, | ||||||
| @@ -8,6 +9,19 @@ import { | |||||||
| import { tools, type Tool } from "@/components/tool"; | import { tools, type Tool } from "@/components/tool"; | ||||||
| import { Layout } from "./layout"; | import { Layout } from "./layout"; | ||||||
|  |  | ||||||
|  | // 加载中的占位组件 | ||||||
|  | const LoadingFallback = () => ( | ||||||
|  |   <div className="flex items-center justify-center h-full"> | ||||||
|  |     <div className="text-center flex flex-col items-center gap-3"> | ||||||
|  |       <div className="relative"> | ||||||
|  |         <div className="h-12 w-12 rounded-full border-4 border-muted"></div> | ||||||
|  |         <div className="absolute top-0 left-0 h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div> | ||||||
|  |       </div> | ||||||
|  |       <p className="text-sm text-muted-foreground font-medium">Loading...</p> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  |  | ||||||
| const buildToolRoutes = (tools: Tool[]): RouteObject[] => { | const buildToolRoutes = (tools: Tool[]): RouteObject[] => { | ||||||
|   return tools.map((tool) => { |   return tools.map((tool) => { | ||||||
|     const route: RouteObject = { |     const route: RouteObject = { | ||||||
| @@ -15,7 +29,12 @@ const buildToolRoutes = (tools: Tool[]): RouteObject[] => { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (tool.component) { |     if (tool.component) { | ||||||
|       route.element = tool.component; |       // 使用 Suspense 包裹懒加载组件 | ||||||
|  |       route.element = ( | ||||||
|  |         <Suspense fallback={<LoadingFallback />}> | ||||||
|  |           {createElement(tool.component)} | ||||||
|  |         </Suspense> | ||||||
|  |       ); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (tool.children && tool.children.length > 0) { |     if (tool.children && tool.children.length > 0) { | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								src/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | /// <reference types="vite/client" /> | ||||||
|  | /// <reference types="vite-plugin-pwa/client" /> | ||||||
|  |  | ||||||
							
								
								
									
										150
									
								
								vite.config.ts
									
									
									
									
									
								
							
							
						
						
									
										150
									
								
								vite.config.ts
									
									
									
									
									
								
							| @@ -2,13 +2,157 @@ import path from "path" | |||||||
| import { defineConfig } from 'vite' | import { defineConfig } from 'vite' | ||||||
| import react from '@vitejs/plugin-react' | import react from '@vitejs/plugin-react' | ||||||
| import tailwindcss from "@tailwindcss/vite" | import tailwindcss from "@tailwindcss/vite" | ||||||
|  | import { VitePWA } from 'vite-plugin-pwa' | ||||||
|  |  | ||||||
| // https://vite.dev/config/ | // https://vite.dev/config/ | ||||||
| export default defineConfig({ | export default defineConfig(({ mode }) => ({ | ||||||
|   plugins: [react(), tailwindcss()], |   plugins: [ | ||||||
|  |     react(),  | ||||||
|  |     tailwindcss(), | ||||||
|  |     // HTML 替换插件 - 仅在生产环境注入 Cloudflare Analytics | ||||||
|  |     { | ||||||
|  |       name: 'html-transform', | ||||||
|  |       transformIndexHtml(html) { | ||||||
|  |         const cloudflareScript = mode === 'production'  | ||||||
|  |           ? `<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "2aecdc025eb043bc89ce931b54a80054"}'></script>` | ||||||
|  |           : ''; | ||||||
|  |         return html.replace('<!--CLOUDFLARE_ANALYTICS_PLACEHOLDER-->', cloudflareScript); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     VitePWA({ | ||||||
|  |       registerType: 'autoUpdate', | ||||||
|  |       includeAssets: ['lite.svg', 'robots.txt', 'sitemap.xml'], | ||||||
|  |       manifest: { | ||||||
|  |         name: 'Lite Kit - Lightweight Online Tools', | ||||||
|  |         short_name: 'Lite Kit', | ||||||
|  |         description: 'Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more', | ||||||
|  |         theme_color: '#000000', | ||||||
|  |         background_color: '#000000', | ||||||
|  |         display: 'standalone', | ||||||
|  |         icons: [ | ||||||
|  |           { | ||||||
|  |             src: '/lite.svg', | ||||||
|  |             type: 'image/svg+xml', | ||||||
|  |             sizes: 'any' | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       }, | ||||||
|  |       workbox: { | ||||||
|  |         globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'], | ||||||
|  |         runtimeCaching: [ | ||||||
|  |           { | ||||||
|  |             urlPattern: /^https:\/\/ipinfo\.io\/.*/i, | ||||||
|  |             handler: "NetworkFirst", | ||||||
|  |             options: { | ||||||
|  |               cacheName: 'ipinfo-cache', | ||||||
|  |               expiration: { | ||||||
|  |                 maxEntries: 10, | ||||||
|  |                 maxAgeSeconds: 60 * 60 // 延长到 1 小时 | ||||||
|  |               }, | ||||||
|  |               cacheableResponse: { | ||||||
|  |                 statuses: [0, 200] | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             // Frankfurter API 汇率缓存 | ||||||
|  |             urlPattern: /^https:\/\/api\.frankfurter\.app\/.*/i, | ||||||
|  |             handler: 'StaleWhileRevalidate', | ||||||
|  |             options: { | ||||||
|  |               cacheName: 'currency-rates-cache', | ||||||
|  |               expiration: { | ||||||
|  |                 maxEntries: 5, | ||||||
|  |                 maxAgeSeconds: 60 * 60 * 12 // 12 小时 | ||||||
|  |               }, | ||||||
|  |               cacheableResponse: { | ||||||
|  |                 statuses: [0, 200] | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             // Google Fonts 样式表缓存 | ||||||
|  |             urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i, | ||||||
|  |             handler: 'StaleWhileRevalidate', | ||||||
|  |             options: { | ||||||
|  |               cacheName: 'google-fonts-stylesheets', | ||||||
|  |               expiration: { | ||||||
|  |                 maxEntries: 10, | ||||||
|  |                 maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年 | ||||||
|  |               }, | ||||||
|  |               cacheableResponse: { | ||||||
|  |                 statuses: [0, 200] | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             // Google Fonts 字体文件缓存 | ||||||
|  |             urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i, | ||||||
|  |             handler: 'CacheFirst', | ||||||
|  |             options: { | ||||||
|  |               cacheName: 'google-fonts-webfonts', | ||||||
|  |               expiration: { | ||||||
|  |                 maxEntries: 30, | ||||||
|  |                 maxAgeSeconds: 60 * 60 * 24 * 365 // 1 年 | ||||||
|  |               }, | ||||||
|  |               cacheableResponse: { | ||||||
|  |                 statuses: [0, 200] | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         ], | ||||||
|  |         cleanupOutdatedCaches: true, | ||||||
|  |         clientsClaim: true,   // 新 SW 激活后立即接管 | ||||||
|  |         skipWaiting: false    // 不自动跳过等待,需要手动触发 | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |   ], | ||||||
|   resolve: { |   resolve: { | ||||||
|     alias: { |     alias: { | ||||||
|       "@": path.resolve(__dirname, "./src"), |       "@": path.resolve(__dirname, "./src"), | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
| }) |   build: { | ||||||
|  |     rollupOptions: { | ||||||
|  |       output: { | ||||||
|  |         manualChunks: (id) => { | ||||||
|  |           // React 核心拆分得更细 | ||||||
|  |           if (id.includes('node_modules/react/') && !id.includes('node_modules/react-dom')) { | ||||||
|  |             return 'react-core'; | ||||||
|  |           } | ||||||
|  |           if (id.includes('node_modules/react-dom/')) { | ||||||
|  |             return 'react-dom'; | ||||||
|  |           } | ||||||
|  |           if (id.includes('node_modules/react-router-dom')) { | ||||||
|  |             return 'react-router'; | ||||||
|  |           } | ||||||
|  |           // Radix UI组件 | ||||||
|  |           if (id.includes('node_modules/@radix-ui')) { | ||||||
|  |             return 'ui-vendor'; | ||||||
|  |           } | ||||||
|  |           // 图标库 | ||||||
|  |           if (id.includes('node_modules/lucide-react')) { | ||||||
|  |             return 'icons'; | ||||||
|  |           } | ||||||
|  |           // 其他工具库 | ||||||
|  |           if (id.includes('node_modules/')) { | ||||||
|  |             return 'vendor'; | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     // 启用更激进的压缩 | ||||||
|  |     minify: 'terser', | ||||||
|  |     terserOptions: { | ||||||
|  |       compress: { | ||||||
|  |         drop_console: true, | ||||||
|  |         drop_debugger: true, | ||||||
|  |         pure_funcs: ['console.log'], | ||||||
|  |         // 移除未使用的代码 | ||||||
|  |         unused: true, | ||||||
|  |         // 移除死代码 | ||||||
|  |         dead_code: true, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     chunkSizeWarningLimit: 500, | ||||||
|  |   }, | ||||||
|  | })) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user