48 Commits

Author SHA1 Message Date
typist
0d6334592d 0.0.31
Some checks failed
Build and Push Docker Image / build (push) Failing after 1m26s
2025-10-30 09:39:49 +08:00
typist
c501bb7dd4 feat: enhance currency caching strategy
- Updated the currency tool to include a timestamp for cached data.
- Implemented a 12-hour validity check for cached exchange rates to improve data accuracy and user experience.
- Ensured that outdated cache is still displayed while fetching new data in the background.
2025-10-30 09:39:34 +08:00
typist
d24389af66 0.0.30
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s
2025-10-30 09:23:32 +08:00
97f38b44f5 feat: add-tool currency (#12)
Co-authored-by: typist <git@mail.typist.cc>
Reviewed-on: #12
2025-10-30 09:22:29 +08:00
typist
62350492e9 0.0.29
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m15s
2025-10-29 21:47:03 +08:00
typist
9540c2b550 feat: improve service worker update notifications
- Added toast notifications for users when a new service worker version is available.
- Updated service worker configuration to require manual activation for updates.
- Enhanced user experience by allowing users to choose when to update the application.
2025-10-29 21:46:54 +08:00
typist
006f3d4dbb 0.0.28
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m26s
2025-10-29 21:37:14 +08:00
typist
ff8d497f97 refactor: simplify SidebarFooter component
- Removed the 'need more tools?' link from the SidebarFooter.
- Updated the layout of SidebarFooter to use a flex column for better alignment.
2025-10-29 21:36:44 +08:00
typist
9c2799f7d5 0.0.27
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s
2025-10-29 14:49:15 +08:00
typist
dd70f9d886 feat: enhance caching strategies for Google Fonts
- Added caching configurations for Google Fonts stylesheets and webfonts.
- Implemented 'StaleWhileRevalidate' for stylesheets and 'CacheFirst' for font files to optimize loading and improve performance.
2025-10-29 14:49:06 +08:00
typist
35cccf6a8f 0.0.26
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m29s
2025-10-29 14:45:03 +08:00
c5616600fa Merge pull request 'feat: add Google Fonts for improved typography' (#11) from feat/update-fonts into main
Reviewed-on: #11
2025-10-29 14:43:59 +08:00
typist
986708fbb4 feat: add Google Fonts for improved typography
- Included preconnect links for Google Fonts to optimize loading.
- Added 'Roboto Mono' and 'Noto Sans SC' font families to enhance text presentation in the application.
2025-10-29 14:44:30 +08:00
typist
6a1b68ed2c 0.0.25
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m32s
2025-10-29 10:05:58 +08:00
typist
32970acf32 chore: support Cloudflare Web Analytics 2025-10-29 10:05:43 +08:00
typist
812bb8c248 0.0.24
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m21s
2025-10-29 09:35:10 +08:00
typist
09f9e6588f fix: update caching strategy for ipinfo API in Vite config
- Changed caching handler from 'CacheFirst' to 'NetworkFirst' to prioritize network responses.
- This adjustment aims to improve data freshness while still utilizing caching effectively.
2025-10-29 09:34:59 +08:00
typist
10a167febd chore: remove SEO-README.md as part of project restructuring
- Deleted the SEO-README.md file which contained outdated SEO optimization details and instructions.
- This change is part of a broader effort to streamline project documentation and improve clarity.
2025-10-29 09:27:54 +08:00
typist
91c0686a46 0.0.23
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m11s
2025-10-29 09:24:17 +08:00
typist
40bfde8e57 feat: implement ID generator component for UUIDs and Nano ID
- Added IDGenerator component to handle the display and regeneration of UUIDs and Nano ID.
- Integrated clipboard functionality to copy generated IDs with user feedback via toast notifications.
- Refactored the Tool component to utilize the new IDGenerator for improved user interaction and code organization.
2025-10-29 09:24:01 +08:00
typist
b4ba7a2219 0.0.22
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m52s
2025-10-29 09:11:11 +08:00
typist
25e42e3af5 fix: update caching strategy for ipinfo API in Vite config
- Changed caching handler from 'NetworkFirst' to 'CacheFirst' for improved performance.
- Extended cache expiration time from 5 minutes to 1 hour to enhance data availability.
2025-10-29 09:10:59 +08:00
typist
4398b53ea7 0.0.21
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m14s
2025-10-29 09:07:27 +08:00
typist
3e14bc652f feat: add terser for advanced minification and improve React chunking
- Added terser as a dependency for enhanced code minification.
- Updated Vite configuration to enable aggressive minification using terser with options to drop console logs and unused code.
- Refined manual chunking for React libraries to improve code splitting and loading efficiency.
2025-10-29 09:06:56 +08:00
typist
aca7e11835 0.0.20
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m10s
2025-10-29 08:58:11 +08:00
typist
782de6e38a feat: integrate PWA support and service worker registration
- Added vite-plugin-pwa for Progressive Web App capabilities.
- Configured service worker with caching strategies for improved offline support.
- Registered service worker in main.tsx to handle updates and caching.
- Updated package.json to include new dependencies for PWA functionality.
2025-10-29 08:58:02 +08:00
typist
7646830194 0.0.19
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m6s
2025-10-29 08:50:18 +08:00
typist
59f998e8e3 feat: enhance tool loading and performance optimization
- Added DNS prefetch and preconnect links in index.html for improved API loading times.
- Implemented lazy loading for tool components to optimize performance and reduce initial load time.
- Wrapped tool components in Suspense with a loading fallback to enhance user experience during loading.
- Refactored Vite configuration to create manual chunks for better code splitting of React and UI libraries.
2025-10-29 08:49:25 +08:00
typist
55207beff5 0.0.18
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m39s
2025-10-29 08:33:42 +08:00
typist
e98a344b95 feat: enhance sidebar component with recursive submenu rendering
- Implemented recursive rendering of submenu items in the sidebar for better navigation.
- Added support for collapsible submenus, improving user experience with nested tools.
- Refactored menu item rendering logic to utilize a new renderSubMenuContent function for cleaner code.
2025-10-29 08:33:22 +08:00
typist
d553c3e04c 0.0.17
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
2025-10-29 08:27:15 +08:00
typist
e2da2758cc chore: simplify build script in package.json
- Removed sitemap generation step from the build script for a more streamlined build process.
2025-10-29 08:27:00 +08:00
typist
3ab70498e6 feat: add ModeToggle component to layout and sidebar
- Integrated ModeToggle component into the layout for theme switching.
- Replaced the existing button in the sidebar footer with SidebarMenuButton for improved styling and functionality.
2025-10-29 08:26:26 +08:00
typist
a5ef1a1e70 0.0.16
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m7s
2025-10-29 08:16:17 +08:00
typist
297000f208 feat: integrate SEO hook for dynamic metadata management
- Removed hardcoded canonical link from index.html.
- Implemented useSEO hook in layout.tsx to dynamically update SEO metadata including title, description, and canonical URL based on the current route.
- Added new use-seo.ts file containing the SEO hook logic for improved search engine optimization.
2025-10-29 08:15:53 +08:00
typist
04fbf12e07 0.0.15
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m6s
2025-10-29 08:06:21 +08:00
a9a6354b2d Merge pull request 'feat: implement SEO enhancements and sitemap generation' (#10) from feat/seo into main
Reviewed-on: #10
2025-10-29 08:05:18 +08:00
typist
109139a42e feat: implement SEO enhancements and sitemap generation
- Added comprehensive SEO meta tags in index.html for improved search engine visibility, including Open Graph and Twitter Card tags.
- Created a sitemap.xml for better indexing of site tools, generated automatically via a new script (generate-sitemap.ts).
- Introduced robots.txt to manage crawler access and specified sitemap location.
- Updated package.json to include a build step for sitemap generation and added tsx as a dependency.
- Documented SEO optimizations and usage instructions in a new SEO-README.md file.
2025-10-29 08:05:37 +08:00
typist
b5a811e5ee 0.0.14
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m0s
2025-10-29 07:28:46 +08:00
typist
b3adfe5c8f refactor: update IP query API and response handling
- Switched from ip-api.com to ipinfo.io for IP information retrieval, ensuring more reliable service.
- Simplified response handling by directly using the data returned from ipinfo.io, eliminating unnecessary data transformation.
- Adjusted risk level determination logic to utilize the 'org' field for identifying potential hosting or datacenter IPs.
2025-10-29 07:28:21 +08:00
typist
8eda2eae99 0.0.13
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m1s
2025-10-29 07:18:09 +08:00
typist
99673913a6 chore: comment out cache settings in build workflow
- Temporarily disabled cache-from and cache-to settings in the build workflow to prevent potential issues with the build process.
- Retained platform specification for compatibility.
2025-10-29 07:18:02 +08:00
typist
83e48e3485 0.0.12
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m40s
2025-10-29 07:10:25 +08:00
typist
8607591871 feat: enhance SVG for dark mode support
- Added CSS styles to the SVG for dark mode compatibility, allowing the icon to switch colors based on the user's color scheme preference.
- Cleaned up the SVG structure for better readability and maintainability.
2025-10-29 07:10:15 +08:00
typist
24c154a759 0.0.11
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m15s
2025-10-29 06:36:32 +08:00
typist
edf87370d9 feat: add IP Query tool
- Introduced a new tool for querying IP information, including location and risk assessment.
- Updated the tool index to include the new IP Query component and its corresponding icon.
- Added IPQuery component with functionality to validate and fetch IP data from external APIs.
2025-10-29 06:36:20 +08:00
typist
ae0f9447ea 0.0.10
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m30s
2025-10-29 06:09:10 +08:00
typist
972b6c7f22 feat: enhance input handling for network tools
- Added domain and URL normalization on blur for DNS, Ping, Speedtest, and TCPing components.
- Improved user experience by ensuring valid input formats and extracting ports where applicable.
2025-10-29 06:08:47 +08:00
32 changed files with 5351 additions and 121 deletions

View File

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

View File

@@ -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 -->
<script defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{"token": "2aecdc025eb043bc89ce931b54a80054"}'></script>
<!-- End Cloudflare Web Analytics -->
</body> </body>
</html> </html>

View File

@@ -1,11 +1,12 @@
{ {
"name": "litek", "name": "litek",
"private": true, "private": true,
"version": "0.0.9", "version": "0.0.31",
"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

File diff suppressed because it is too large Load Diff

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

21
public/manifest.json Normal file
View File

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

21
public/robots.txt Normal file
View File

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

63
public/sitemap.xml Normal file
View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem, SidebarMenu, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton } from "@/components/ui/sidebar";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { tools, type Tool } from "@/components/tool"; import { tools, type Tool } from "@/components/tool";
import { Link } from "react-router-dom";
import { ModeToggle } from "@/components/theme/toggle";
import { Button } from "../ui/button";
import { ChevronRight } from "lucide-react";
export const AppSidebar = () => { export const AppSidebar = () => {
// 递归构建完整路径 // 递归构建完整路径
@@ -13,6 +13,46 @@ export const AppSidebar = () => {
return `/tool/${pathSegments.join("/")}`; return `/tool/${pathSegments.join("/")}`;
}; };
// 递归渲染子菜单内容
const renderSubMenuContent = (child: Tool, currentPaths: string[]): ReactNode => {
if (child.children) {
// 子菜单内的可折叠项
return (
<Collapsible defaultOpen={false} className="group/collapsible">
<CollapsibleTrigger asChild>
<SidebarMenuSubButton>
{child.icon}
<span>{child.name}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuSubButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{child.children.map((subChild) => (
<SidebarMenuSubItem key={subChild.name}>
{renderSubMenuContent(subChild, [...currentPaths, child.path])}
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
);
}
// 叶子节点
return (
<SidebarMenuSubButton asChild>
<Link
to={buildFullPath([...currentPaths, child.path])}
title={child.description}
>
{child.icon}
<span>{child.name}</span>
</Link>
</SidebarMenuSubButton>
);
};
// 递归渲染菜单项 // 递归渲染菜单项
const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => { const renderMenuItem = (tool: Tool, parentPaths: string[] = []): ReactNode => {
const currentPaths = [...parentPaths, tool.path]; const currentPaths = [...parentPaths, tool.path];
@@ -20,12 +60,11 @@ export const AppSidebar = () => {
if (tool.children) { if (tool.children) {
// 有子菜单的项目 // 有子菜单的项目
return ( return (
<Collapsible <SidebarMenuItem key={tool.name}>
key={tool.name} <Collapsible
defaultOpen={false} defaultOpen={false}
className="group/collapsible" className="group/collapsible"
> >
<SidebarMenuItem>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={tool.description}> <SidebarMenuButton tooltip={tool.description}>
{tool.icon} {tool.icon}
@@ -37,25 +76,13 @@ export const AppSidebar = () => {
<SidebarMenuSub> <SidebarMenuSub>
{tool.children.map((child) => ( {tool.children.map((child) => (
<SidebarMenuSubItem key={child.name}> <SidebarMenuSubItem key={child.name}>
{child.children ? ( {renderSubMenuContent(child, currentPaths)}
renderMenuItem(child, currentPaths)
) : (
<SidebarMenuSubButton asChild>
<Link
to={buildFullPath([...currentPaths, child.path])}
title={child.description}
>
{child.icon}
<span>{child.name}</span>
</Link>
</SidebarMenuSubButton>
)}
</SidebarMenuSubItem> </SidebarMenuSubItem>
))} ))}
</SidebarMenuSub> </SidebarMenuSub>
</CollapsibleContent> </CollapsibleContent>
</SidebarMenuItem> </Collapsible>
</Collapsible> </SidebarMenuItem>
); );
} }
@@ -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>
); );
}; };

View File

@@ -0,0 +1,469 @@
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 RATES_CACHE_KEY = "currencyRatesCache";
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 with date-based caching
useEffect(() => {
const fetchRates = async () => {
try {
// 1. Try to load from cache
const cached = localStorage.getItem(RATES_CACHE_KEY);
if (cached) {
try {
const { rates: cachedRates, date: cachedDate, fetchedAt } = JSON.parse(cached);
const now = Date.now();
const twelveHoursInMs = 12 * 60 * 60 * 1000;
// Strategy: Use cache if it's recent enough (within 12 hours)
// This handles all edge cases: weekends, holidays, timezone differences
// Exchange rates update once per day, so 12-hour cache is reasonable
if (fetchedAt && (now - fetchedAt < twelveHoursInMs)) {
setRates(cachedRates);
setLoading(false);
return;
} else {
// Cache is older than 12 hours, show it first then update in background
setRates(cachedRates);
setLoading(false);
// Continue to fetch new data below
}
} catch (e) {
console.error("Failed to parse cached rates:", e);
}
}
// 2. Fetch latest data from network
setLoading(true);
const response = await fetch("https://api.frankfurter.app/latest?base=USD", {
cache: "no-cache",
});
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 };
const apiDate = data.date; // Date from API (YYYY-MM-DD format)
// 3. Update state and cache (with timestamp)
setRates(allRates);
localStorage.setItem(
RATES_CACHE_KEY,
JSON.stringify({
rates: allRates,
date: apiDate,
fetchedAt: Date.now() // Timestamp when we fetched the data
})
);
} catch (error) {
// If cache exists, continue using it even if network fails
const cached = localStorage.getItem(RATES_CACHE_KEY);
if (cached) {
try {
const { rates: cachedRates } = JSON.parse(cached);
setRates(cachedRates);
toast.info("Using cached exchange rates (network request failed)");
} catch (e) {
console.error("Failed to use cached rates:", e);
if (error instanceof Error) {
toast.error(`Failed to fetch rates: ${error.message}`);
} else {
toast.error("Failed to fetch rates");
}
}
} else {
if (error instanceof Error) {
toast.error(`Failed to fetch rates: ${error.message}`);
} else {
toast.error("Failed to fetch 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;

View File

@@ -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 } 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 } 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,28 +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",
name: "IP Query",
description: "Query IP location, quality and risk info",
icon: <MapPin />,
component: IPQuery,
}, },
], ],
}, },

View File

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

View File

@@ -1,5 +0,0 @@
export { default as DNS } from './dns';
export { default as Ping } from './ping';
export { default as TCPing } from './tcping';
export { default as SpeedTest } from './speedtest';

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
); );
}; };

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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 }

96
src/hooks/use-seo.ts Normal file
View File

@@ -0,0 +1,96 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
interface UseSEOOptions {
title?: string;
description?: string;
baseUrl?: string;
}
/**
* SEO Hook - 动态更新页面 SEO 元数据
*
* @param options - SEO 配置选项
* @param options.title - 页面标题(可选)
* @param options.description - 页面描述(可选)
* @param options.baseUrl - 网站基础 URL默认为 https://litek.typist.cc
*
* @example
* ```tsx
* // 在组件中使用
* useSEO({
* title: 'UUID Generator',
* description: 'Free online UUID generator tool'
* });
* ```
*/
export const useSEO = (options: UseSEOOptions = {}) => {
const location = useLocation();
const {
title,
description,
baseUrl = 'https://litek.typist.cc'
} = options;
useEffect(() => {
// 构建当前页面的完整 URL
const canonicalUrl = `${baseUrl}${location.pathname}`;
// 更新或创建 canonical 链接
let canonical = document.querySelector('link[rel="canonical"]') as HTMLLinkElement;
if (!canonical) {
canonical = document.createElement('link');
canonical.setAttribute('rel', 'canonical');
document.head.appendChild(canonical);
}
canonical.setAttribute('href', canonicalUrl);
// 更新页面标题
if (title) {
document.title = `${title} - Lite Kit`;
}
// 更新 meta description
if (description) {
let metaDescription = document.querySelector('meta[name="description"]') as HTMLMetaElement;
if (metaDescription) {
metaDescription.setAttribute('content', description);
}
}
// 更新 Open Graph URL
let ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement;
if (ogUrl) {
ogUrl.setAttribute('content', canonicalUrl);
}
// 更新 Open Graph Title
if (title) {
let ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement;
if (ogTitle) {
ogTitle.setAttribute('content', `${title} - Lite Kit`);
}
// 更新 Twitter Card Title
let twitterTitle = document.querySelector('meta[name="twitter:title"]') as HTMLMetaElement;
if (twitterTitle) {
twitterTitle.setAttribute('content', `${title} - Lite Kit`);
}
}
// 更新 Open Graph Description
if (description) {
let ogDescription = document.querySelector('meta[property="og:description"]') as HTMLMetaElement;
if (ogDescription) {
ogDescription.setAttribute('content', description);
}
// 更新 Twitter Card Description
let twitterDescription = document.querySelector('meta[name="twitter:description"]') as HTMLMetaElement;
if (twitterDescription) {
twitterDescription.setAttribute('content', description);
}
}
}, [location.pathname, title, description, baseUrl]);
};

View File

@@ -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;
} }
} }

View File

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

View File

@@ -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)
})
}

View File

@@ -1,3 +1,4 @@
import { Suspense, createElement } from "react";
import { import {
createBrowserRouter, createBrowserRouter,
redirect, redirect,
@@ -8,6 +9,19 @@ import {
import { tools, type Tool } from "@/components/tool"; import { tools, type Tool } from "@/components/tool";
import { Layout } from "./layout"; import { Layout } from "./layout";
// 加载中的占位组件
const LoadingFallback = () => (
<div className="flex items-center justify-center h-full">
<div className="text-center flex flex-col items-center gap-3">
<div className="relative">
<div className="h-12 w-12 rounded-full border-4 border-muted"></div>
<div className="absolute top-0 left-0 h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin"></div>
</div>
<p className="text-sm text-muted-foreground font-medium">Loading...</p>
</div>
</div>
);
const buildToolRoutes = (tools: Tool[]): RouteObject[] => { const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
return tools.map((tool) => { return tools.map((tool) => {
const route: RouteObject = { const route: RouteObject = {
@@ -15,7 +29,12 @@ const buildToolRoutes = (tools: Tool[]): RouteObject[] => {
}; };
if (tool.component) { if (tool.component) {
route.element = tool.component; // 使用 Suspense 包裹懒加载组件
route.element = (
<Suspense fallback={<LoadingFallback />}>
{createElement(tool.component)}
</Suspense>
);
} }
if (tool.children && tool.children.length > 0) { if (tool.children && tool.children.length > 0) {

3
src/vite-env.d.ts vendored Normal file
View File

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

View File

@@ -2,13 +2,132 @@ import path from "path"
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['lite.svg', 'robots.txt', 'sitemap.xml'],
manifest: {
name: 'Lite Kit - Lightweight Online Tools',
short_name: 'Lite Kit',
description: 'Free online tools including UUID generator, JSON formatter, Base64 encoder/decoder, network testing tools and more',
theme_color: '#000000',
background_color: '#000000',
display: 'standalone',
icons: [
{
src: '/lite.svg',
type: 'image/svg+xml',
sizes: 'any'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/ipinfo\.io\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: 'ipinfo-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 // 延长到 1 小时
},
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,
},
}) })