28 Commits

Author SHA1 Message Date
typist
b4ba7a2219 0.0.22
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m52s
2025-10-29 09:11:11 +08:00
typist
25e42e3af5 fix: update caching strategy for ipinfo API in Vite config
- Changed caching handler from 'NetworkFirst' to 'CacheFirst' for improved performance.
- Extended cache expiration time from 5 minutes to 1 hour to enhance data availability.
2025-10-29 09:10:59 +08:00
typist
4398b53ea7 0.0.21
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s
2025-10-29 09:07:27 +08:00
typist
3e14bc652f feat: add terser for advanced minification and improve React chunking
- Added terser as a dependency for enhanced code minification.
- Updated Vite configuration to enable aggressive minification using terser with options to drop console logs and unused code.
- Refined manual chunking for React libraries to improve code splitting and loading efficiency.
2025-10-29 09:06:56 +08:00
typist
aca7e11835 0.0.20
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m10s
2025-10-29 08:58:11 +08:00
typist
782de6e38a feat: integrate PWA support and service worker registration
- Added vite-plugin-pwa for Progressive Web App capabilities.
- Configured service worker with caching strategies for improved offline support.
- Registered service worker in main.tsx to handle updates and caching.
- Updated package.json to include new dependencies for PWA functionality.
2025-10-29 08:58:02 +08:00
typist
7646830194 0.0.19
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m6s
2025-10-29 08:50:18 +08:00
typist
59f998e8e3 feat: enhance tool loading and performance optimization
- Added DNS prefetch and preconnect links in index.html for improved API loading times.
- Implemented lazy loading for tool components to optimize performance and reduce initial load time.
- Wrapped tool components in Suspense with a loading fallback to enhance user experience during loading.
- Refactored Vite configuration to create manual chunks for better code splitting of React and UI libraries.
2025-10-29 08:49:25 +08:00
typist
55207beff5 0.0.18
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m39s
2025-10-29 08:33:42 +08:00
typist
e98a344b95 feat: enhance sidebar component with recursive submenu rendering
- Implemented recursive rendering of submenu items in the sidebar for better navigation.
- Added support for collapsible submenus, improving user experience with nested tools.
- Refactored menu item rendering logic to utilize a new renderSubMenuContent function for cleaner code.
2025-10-29 08:33:22 +08:00
typist
d553c3e04c 0.0.17
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
2025-10-29 08:27:15 +08:00
typist
e2da2758cc chore: simplify build script in package.json
- Removed sitemap generation step from the build script for a more streamlined build process.
2025-10-29 08:27:00 +08:00
typist
3ab70498e6 feat: add ModeToggle component to layout and sidebar
- Integrated ModeToggle component into the layout for theme switching.
- Replaced the existing button in the sidebar footer with SidebarMenuButton for improved styling and functionality.
2025-10-29 08:26:26 +08:00
typist
a5ef1a1e70 0.0.16
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m7s
2025-10-29 08:16:17 +08:00
typist
297000f208 feat: integrate SEO hook for dynamic metadata management
- Removed hardcoded canonical link from index.html.
- Implemented useSEO hook in layout.tsx to dynamically update SEO metadata including title, description, and canonical URL based on the current route.
- Added new use-seo.ts file containing the SEO hook logic for improved search engine optimization.
2025-10-29 08:15:53 +08:00
typist
04fbf12e07 0.0.15
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m6s
2025-10-29 08:06:21 +08:00
a9a6354b2d Merge pull request 'feat: implement SEO enhancements and sitemap generation' (#10) from feat/seo into main
Reviewed-on: #10
2025-10-29 08:05:18 +08:00
typist
109139a42e feat: implement SEO enhancements and sitemap generation
- Added comprehensive SEO meta tags in index.html for improved search engine visibility, including Open Graph and Twitter Card tags.
- Created a sitemap.xml for better indexing of site tools, generated automatically via a new script (generate-sitemap.ts).
- Introduced robots.txt to manage crawler access and specified sitemap location.
- Updated package.json to include a build step for sitemap generation and added tsx as a dependency.
- Documented SEO optimizations and usage instructions in a new SEO-README.md file.
2025-10-29 08:05:37 +08:00
typist
b5a811e5ee 0.0.14
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m0s
2025-10-29 07:28:46 +08:00
typist
b3adfe5c8f refactor: update IP query API and response handling
- Switched from ip-api.com to ipinfo.io for IP information retrieval, ensuring more reliable service.
- Simplified response handling by directly using the data returned from ipinfo.io, eliminating unnecessary data transformation.
- Adjusted risk level determination logic to utilize the 'org' field for identifying potential hosting or datacenter IPs.
2025-10-29 07:28:21 +08:00
typist
8eda2eae99 0.0.13
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
2025-10-29 07:18:09 +08:00
typist
99673913a6 chore: comment out cache settings in build workflow
- Temporarily disabled cache-from and cache-to settings in the build workflow to prevent potential issues with the build process.
- Retained platform specification for compatibility.
2025-10-29 07:18:02 +08:00
typist
83e48e3485 0.0.12
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m40s
2025-10-29 07:10:25 +08:00
typist
8607591871 feat: enhance SVG for dark mode support
- Added CSS styles to the SVG for dark mode compatibility, allowing the icon to switch colors based on the user's color scheme preference.
- Cleaned up the SVG structure for better readability and maintainability.
2025-10-29 07:10:15 +08:00
typist
24c154a759 0.0.11
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m15s
2025-10-29 06:36:32 +08:00
typist
edf87370d9 feat: add IP Query tool
- Introduced a new tool for querying IP information, including location and risk assessment.
- Updated the tool index to include the new IP Query component and its corresponding icon.
- Added IPQuery component with functionality to validate and fetch IP data from external APIs.
2025-10-29 06:36:20 +08:00
typist
ae0f9447ea 0.0.10
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
2025-10-29 06:09:10 +08:00
typist
972b6c7f22 feat: enhance input handling for network tools
- Added domain and URL normalization on blur for DNS, Ping, Speedtest, and TCPing components.
- Improved user experience by ensuring valid input formats and extracting ports where applicable.
2025-10-29 06:08:47 +08:00
24 changed files with 4278 additions and 104 deletions

View File

@@ -43,8 +43,8 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache # cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache
cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max # cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max
# platforms: linux/amd64,linux/arm64 # platforms: linux/amd64,linux/arm64
platforms: linux/amd64 platforms: linux/amd64

171
SEO-README.md Normal file
View File

@@ -0,0 +1,171 @@
# 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

View File

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

View File

@@ -1,11 +1,12 @@
{ {
"name": "litek", "name": "litek",
"private": true, "private": true,
"version": "0.0.9", "version": "0.0.22",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"generate:sitemap": "tsx scripts/generate-sitemap.ts",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"release:patch": "npm version patch && git push --follow-tags", "release:patch": "npm version patch && git push --follow-tags",
@@ -43,17 +44,22 @@
"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",
"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": {
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.1.14"
}, },
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@swc/core" "@swc/core",
"esbuild"
] ]
} }
} }

3108
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

21
public/manifest.json Normal file
View File

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

21
public/robots.txt Normal file
View File

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

63
public/sitemap.xml Normal file
View File

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

View File

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

View File

@@ -1,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 (
<Collapsible <SidebarMenuItem key={tool.name}>
key={tool.name} <Collapsible
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>
); );
} }
@@ -88,10 +115,9 @@ export const AppSidebar = () => {
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="flex flex-row justify-between items-center gap-2"> <SidebarFooter className="flex flex-row justify-between items-center gap-2">
<Button variant="link"> <SidebarMenuButton variant="outline" asChild>
<a href="mailto:litek@mail.typist.cc">need more tools?</a> <a href="mailto:litek@mail.typist.cc">need more tools?</a>
</Button> </SidebarMenuButton>
<ModeToggle />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
); );

View File

@@ -1,17 +1,22 @@
import type { ReactNode } from 'react'; import { lazy, type ReactNode, type ComponentType } from 'react';
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi } from 'lucide-react' import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } 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 } from './network' const Base64 = lazy(() => import('./base64'))
const DNS = lazy(() => import('./network/dns'))
const Ping = lazy(() => import('./network/ping'))
const TCPing = lazy(() => import('./network/tcping'))
const SpeedTest = lazy(() => import('./network/speedtest'))
const IPQuery = lazy(() => import('./network/ipquery'))
export interface Tool { 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 +26,21 @@ 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: "network", path: "network",
@@ -48,28 +53,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",
name: "IP Query",
description: "Query IP location, quality and risk info",
icon: <MapPin />,
component: IPQuery,
}, },
], ],
}, },

View File

@@ -41,6 +41,26 @@ const Tool: FC = () => {
const [results, setResults] = useState<DNSRecord[]>([]); const [results, setResults] = useState<DNSRecord[]>([]);
const [queryTime, setQueryTime] = useState<number>(0); const [queryTime, setQueryTime] = useState<number>(0);
const handleDomainBlur = () => {
if (!domain.trim()) return;
let input = domain.trim();
let cleanDomain = input;
try {
// Try to parse as URL
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
cleanDomain = url.hostname;
} catch {
// If parsing fails, fallback to manual cleanup
cleanDomain = input.replace(/^https?:\/\//, "").split("/")[0].split(":")[0];
}
if (cleanDomain !== input) {
setDomain(cleanDomain);
}
};
const queryDNS = async () => { const queryDNS = async () => {
if (!domain.trim()) { if (!domain.trim()) {
toast.error("Please enter a domain name"); toast.error("Please enter a domain name");
@@ -58,7 +78,7 @@ const Tool: FC = () => {
const queries = DNS_RECORD_TYPES.map((recordType) => const queries = DNS_RECORD_TYPES.map((recordType) =>
fetch( fetch(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent( `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(
domain domain.trim()
)}&type=${recordType.value}`, )}&type=${recordType.value}`,
{ {
headers: { headers: {
@@ -127,6 +147,7 @@ const Tool: FC = () => {
placeholder="e.g. example.com" placeholder="e.g. example.com"
value={domain} value={domain}
onChange={(e) => setDomain(e.target.value)} onChange={(e) => setDomain(e.target.value)}
onBlur={handleDomainBlur}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={loading} disabled={loading}
/> />

View File

@@ -1,5 +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';

View File

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

View File

@@ -36,6 +36,27 @@ const Tool: FC = () => {
const seqRef = useRef<number>(0); const seqRef = useRef<number>(0);
const resultsContainerRef = useRef<HTMLDivElement>(null); const resultsContainerRef = useRef<HTMLDivElement>(null);
const handleUrlBlur = () => {
if (!url.trim()) return;
let input = url.trim();
try {
// Try to parse as URL
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
const normalizedUrl = parsedUrl.toString();
if (normalizedUrl !== input) {
setUrl(normalizedUrl);
}
} catch {
// If parsing fails, add https:// prefix
if (!input.startsWith("http://") && !input.startsWith("https://")) {
setUrl(`https://${input}`);
}
}
};
const ping = async () => { const ping = async () => {
if (!url.trim()) { if (!url.trim()) {
toast.error("Please enter a URL"); toast.error("Please enter a URL");
@@ -43,12 +64,7 @@ const Tool: FC = () => {
} }
const seq = ++seqRef.current; const seq = ++seqRef.current;
let targetUrl = url.trim(); const targetUrl = url.trim();
// If no protocol prefix, default to https://
if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) {
targetUrl = `https://${targetUrl}`;
}
const startTime = performance.now(); const startTime = performance.now();
@@ -177,6 +193,7 @@ const Tool: FC = () => {
placeholder="e.g. example.com or https://example.com" placeholder="e.g. example.com or https://example.com"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
disabled={running} disabled={running}
/> />
</div> </div>

View File

@@ -142,18 +142,34 @@ const Tool: FC = () => {
} }
}; };
const handleUrlBlur = () => {
if (!url.trim()) return;
let input = url.trim();
try {
// Try to parse as URL
const parsedUrl = new URL(input.startsWith('http') ? input : `https://${input}`);
const normalizedUrl = parsedUrl.toString();
if (normalizedUrl !== input) {
setUrl(normalizedUrl);
}
} catch {
// If parsing fails, add https:// prefix
if (!input.startsWith("http://") && !input.startsWith("https://")) {
setUrl(`https://${input}`);
}
}
};
const startTest = async () => { const startTest = async () => {
if (!url.trim()) { if (!url.trim()) {
toast.error("Please enter a URL"); toast.error("Please enter a URL");
return; return;
} }
let targetUrl = url.trim(); const targetUrl = url.trim();
// If no protocol prefix, default to https://
if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) {
targetUrl = `https://${targetUrl}`;
}
setTesting(true); setTesting(true);
setResult(null); setResult(null);
@@ -218,6 +234,7 @@ const Tool: FC = () => {
placeholder="e.g. https://example.com" placeholder="e.g. https://example.com"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
disabled={testing} disabled={testing}
/> />

View File

@@ -37,6 +37,46 @@ const Tool: FC = () => {
const seqRef = useRef<number>(0); const seqRef = useRef<number>(0);
const resultsContainerRef = useRef<HTMLDivElement>(null); const resultsContainerRef = useRef<HTMLDivElement>(null);
const handleHostBlur = () => {
if (!host.trim()) return;
let input = host.trim();
let cleanHost = input;
let extractedPort: string | null = null;
try {
// Try to parse as URL
const url = new URL(input.startsWith('http') ? input : `https://${input}`);
cleanHost = url.hostname;
// Extract port if specified in URL
if (url.port) {
extractedPort = url.port;
}
} catch {
// If parsing fails, fallback to manual cleanup
const withoutProtocol = input.replace(/^https?:\/\//, "");
const withoutPath = withoutProtocol.split("/")[0];
// Check for port in the format hostname:port
const portMatch = withoutPath.match(/^(.+):(\d+)$/);
if (portMatch) {
cleanHost = portMatch[1];
extractedPort = portMatch[2];
} else {
cleanHost = withoutPath;
}
}
if (cleanHost !== input) {
setHost(cleanHost);
}
if (extractedPort) {
setPort(extractedPort);
}
};
const tcping = async () => { const tcping = async () => {
if (!host.trim()) { if (!host.trim()) {
toast.error("Please enter a hostname or IP"); toast.error("Please enter a hostname or IP");
@@ -45,14 +85,11 @@ const Tool: FC = () => {
const seq = ++seqRef.current; const seq = ++seqRef.current;
const portNum = parseInt(port) || 443; const portNum = parseInt(port) || 443;
let targetUrl = host.trim(); const targetHost = host.trim();
// 移除协议前缀 // Build test URL
targetUrl = targetUrl.replace(/^https?:\/\//, "");
// 构建测试 URL
const protocol = portNum === 443 ? "https" : "http"; const protocol = portNum === 443 ? "https" : "http";
const url = `${protocol}://${targetUrl}:${portNum}`; const url = `${protocol}://${targetHost}:${portNum}`;
const startTime = performance.now(); const startTime = performance.now();
@@ -182,6 +219,7 @@ const Tool: FC = () => {
placeholder="e.g. example.com or 192.168.1.1" placeholder="e.g. example.com or 192.168.1.1"
value={host} value={host}
onChange={(e) => setHost(e.target.value)} onChange={(e) => setHost(e.target.value)}
onBlur={handleHostBlur}
disabled={running} disabled={running}
/> />
</div> </div>

96
src/hooks/use-seo.ts Normal file
View 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]);
};

View File

@@ -4,20 +4,28 @@ 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";
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<SidebarProvider> export const Layout: FC = () => {
<AppSidebar /> // 使用 SEO hook 自动更新 canonical URL 和其他 SEO 元数据
<div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden"> useSEO();
<nav className="flex items-center justify-between">
<SidebarTrigger className="size-10" /> return (
<div role="actions" /> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
</nav> <SidebarProvider>
<main className="flex-1 overflow-auto p-4 overflow-hidden"> <AppSidebar />
<Outlet /> <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden">
</main> <nav className="flex items-center justify-between">
</div> <SidebarTrigger className="size-10" />
</SidebarProvider> <ModeToggle />
</ThemeProvider> </nav>
); <main className="flex-1 overflow-auto p-4 overflow-hidden">
<Outlet />
</main>
</div>
</SidebarProvider>
</ThemeProvider>
);
};

View File

@@ -13,3 +13,21 @@ 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('installed', (event) => {
if (event.isUpdate) {
console.log('New service worker installed, reloading page...')
window.location.reload()
}
})
wb.register()
}).catch((error) => {
console.error('Failed to register service worker:', error)
})
}

View File

@@ -1,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
View File

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

View File

@@ -2,13 +2,102 @@ 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({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['lite.svg', 'robots.txt', 'sitemap.xml'],
manifest: {
name: 'Lite Kit - Lightweight Online Tools',
short_name: 'Lite Kit',
description: 'Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more',
theme_color: '#000000',
background_color: '#000000',
display: 'standalone',
icons: [
{
src: '/lite.svg',
type: 'image/svg+xml',
sizes: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/ipinfo\.io\/.*/i,
handler: 'CacheFirst', // 改为 CacheFirst优先使用缓存
options: {
cacheName: 'ipinfo-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 // 延长到 1 小时
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
}
})
],
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,
},
}) })