Compare commits
51 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 | ||
|
|
04fbf12e07 | ||
| a9a6354b2d | |||
|
|
109139a42e | ||
|
|
b5a811e5ee | ||
|
|
b3adfe5c8f | ||
|
|
8eda2eae99 | ||
|
|
99673913a6 | ||
|
|
83e48e3485 | ||
|
|
8607591871 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
63
index.html
63
index.html
@@ -2,12 +2,71 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
<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>
|
||||||
<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>
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "litek",
|
"name": "litek",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.11",
|
"version": "0.0.34",
|
||||||
"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",
|
||||||
@@ -16,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",
|
||||||
@@ -43,17 +47,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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3193
pnpm-lock.yaml
generated
3193
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
21
public/manifest.json
Normal 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
21
public/robots.txt
Normal 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
63
public/sitemap.xml
Normal 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>
|
||||||
83
scripts/generate-sitemap.ts
Normal file
83
scripts/generate-sitemap.ts
Normal 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}`);
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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';
|
|
||||||
|
|
||||||
@@ -91,8 +91,8 @@ const Tool: FC = () => {
|
|||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 ip-api.com (免费,功能较全)
|
// 使用 ipinfo.io (免费,稳定可靠)
|
||||||
const response = await fetch(`http://ip-api.com/json/${encodeURIComponent(ip.trim())}?fields=status,message,country,countryCode,region,city,lat,lon,timezone,isp,org,as,proxy,hosting,query`);
|
const response = await fetch(`https://ipinfo.io/${encodeURIComponent(ip.trim())}/json`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
@@ -103,28 +103,8 @@ const Tool: FC = () => {
|
|||||||
|
|
||||||
setQueryTime(endTime - startTime);
|
setQueryTime(endTime - startTime);
|
||||||
|
|
||||||
if (data.status === "fail") {
|
// ipinfo.io 返回格式已经符合 IPInfo 接口
|
||||||
toast.error(data.message || "Query failed");
|
setIpInfo(data);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换为统一格式
|
|
||||||
const ipData: IPInfo = {
|
|
||||||
ip: data.query,
|
|
||||||
city: data.city,
|
|
||||||
region: data.region,
|
|
||||||
country: data.country,
|
|
||||||
countryCode: data.countryCode,
|
|
||||||
loc: data.lat && data.lon ? `${data.lat},${data.lon}` : undefined,
|
|
||||||
timezone: data.timezone,
|
|
||||||
isp: data.isp,
|
|
||||||
org: data.org,
|
|
||||||
as: data.as,
|
|
||||||
proxy: data.proxy,
|
|
||||||
hosting: data.hosting,
|
|
||||||
};
|
|
||||||
|
|
||||||
setIpInfo(ipData);
|
|
||||||
toast.success("Query successful");
|
toast.success("Query successful");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -146,14 +126,18 @@ const Tool: FC = () => {
|
|||||||
const getRiskLevel = () => {
|
const getRiskLevel = () => {
|
||||||
if (!ipInfo) return null;
|
if (!ipInfo) return null;
|
||||||
|
|
||||||
if (ipInfo.proxy || ipInfo.hosting) {
|
// 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 {
|
return {
|
||||||
level: "High",
|
level: "Medium",
|
||||||
color: "text-red-500",
|
color: "text-yellow-500",
|
||||||
reasons: [
|
reasons: ["Possible Hosting/Datacenter IP"],
|
||||||
ipInfo.proxy && "Proxy/VPN detected",
|
|
||||||
ipInfo.hosting && "Hosting/Datacenter IP",
|
|
||||||
].filter(Boolean),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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