11 Commits

Author SHA1 Message Date
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
12 changed files with 3112 additions and 78 deletions

View File

@@ -13,7 +13,10 @@
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/lite.svg" /> <link rel="icon" type="image/svg+xml" href="/lite.svg" />
<link rel="canonical" href="https://litek.typist.cc/" />
<!-- DNS Prefetch & Preconnect for external APIs -->
<link rel="dns-prefetch" href="https://ipinfo.io">
<link rel="preconnect" href="https://ipinfo.io" crossorigin>
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />

View File

@@ -1,11 +1,11 @@
{ {
"name": "litek", "name": "litek",
"private": true, "private": true,
"version": "0.0.15", "version": "0.0.20",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "npm run generate:sitemap && tsc -b && vite build", "build": "tsc -b && vite build",
"generate:sitemap": "tsx scripts/generate-sitemap.ts", "generate:sitemap": "tsx scripts/generate-sitemap.ts",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
@@ -48,7 +48,9 @@
"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": {

2808
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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 (
<SidebarMenuItem key={tool.name}>
<Collapsible <Collapsible
key={tool.name}
defaultOpen={false} defaultOpen={false}
className="group/collapsible" className="group/collapsible"
> >
<SidebarMenuItem>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={tool.description}> <SidebarMenuButton tooltip={tool.description}>
{tool.icon} {tool.icon}
@@ -37,25 +76,13 @@ export const AppSidebar = () => {
<SidebarMenuSub> <SidebarMenuSub>
{tool.children.map((child) => ( {tool.children.map((child) => (
<SidebarMenuSubItem key={child.name}> <SidebarMenuSubItem key={child.name}>
{child.children ? ( {renderSubMenuContent(child, currentPaths)}
renderMenuItem(child, currentPaths)
) : (
<SidebarMenuSubButton asChild>
<Link
to={buildFullPath([...currentPaths, child.path])}
title={child.description}
>
{child.icon}
<span>{child.name}</span>
</Link>
</SidebarMenuSubButton>
)}
</SidebarMenuSubItem> </SidebarMenuSubItem>
))} ))}
</SidebarMenuSub> </SidebarMenuSub>
</CollapsibleContent> </CollapsibleContent>
</SidebarMenuItem>
</Collapsible> </Collapsible>
</SidebarMenuItem>
); );
} }
@@ -88,10 +115,9 @@ export const AppSidebar = () => {
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="flex flex-row justify-between items-center gap-2"> <SidebarFooter className="flex flex-row justify-between items-center gap-2">
<Button variant="link"> <SidebarMenuButton variant="outline" asChild>
<a href="mailto:litek@mail.typist.cc">need more tools?</a> <a href="mailto:litek@mail.typist.cc">need more tools?</a>
</Button> </SidebarMenuButton>
<ModeToggle />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
); );

View File

@@ -1,17 +1,22 @@
import type { ReactNode } from 'react'; import { lazy, type ReactNode, type ComponentType } from 'react';
import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react' import { FileJson, Hash, Binary, Network, Globe, Activity, Gauge, Wifi, MapPin } from 'lucide-react'
import UUID from './uuid' // 懒加载工具组件
import JSON from './json' const UUID = lazy(() => import('./uuid'))
import Base64 from './base64' const JSON = lazy(() => import('./json'))
import { DNS, Ping, TCPing, SpeedTest, IPQuery } from './network' const Base64 = lazy(() => import('./base64'))
const DNS = lazy(() => import('./network/dns'))
const Ping = lazy(() => import('./network/ping'))
const TCPing = lazy(() => import('./network/tcping'))
const SpeedTest = lazy(() => import('./network/speedtest'))
const IPQuery = lazy(() => import('./network/ipquery'))
export interface Tool { export interface Tool {
path: string; path: string;
name: string; name: string;
icon: ReactNode; icon: ReactNode;
description: string; description: string;
component?: ReactNode; component?: ComponentType;
children?: Tool[]; children?: Tool[];
} }
@@ -21,21 +26,21 @@ export const tools: Tool[] = [
name: "UUID Generator", name: "UUID Generator",
description: "Generate a UUID", description: "Generate a UUID",
icon: <Hash />, icon: <Hash />,
component: <UUID />, component: UUID,
}, },
{ {
path: "json", path: "json",
name: "JSON Formatter", name: "JSON Formatter",
description: "Format and validate JSON", description: "Format and validate JSON",
icon: <FileJson />, icon: <FileJson />,
component: <JSON />, component: JSON,
}, },
{ {
path: "base64", path: "base64",
name: "Base64 Encoder/Decoder", name: "Base64 Encoder/Decoder",
description: "Encode and decode Base64", description: "Encode and decode Base64",
icon: <Binary />, icon: <Binary />,
component: <Base64 />, component: Base64,
}, },
{ {
path: "network", path: "network",
@@ -48,35 +53,35 @@ export const tools: Tool[] = [
name: "DNS Lookup", name: "DNS Lookup",
description: "DNS query tool", description: "DNS query tool",
icon: <Globe />, icon: <Globe />,
component: <DNS />, component: DNS,
}, },
{ {
path: "ping", path: "ping",
name: "Ping", name: "Ping",
description: "Ping test tool", description: "Ping test tool",
icon: <Activity />, icon: <Activity />,
component: <Ping />, component: Ping,
}, },
{ {
path: "tcping", path: "tcping",
name: "TCPing", name: "TCPing",
description: "TCP port connectivity test", description: "TCP port connectivity test",
icon: <Wifi />, icon: <Wifi />,
component: <TCPing />, component: TCPing,
}, },
{ {
path: "speedtest", path: "speedtest",
name: "Speed Test", name: "Speed Test",
description: "Website speed test", description: "Website speed test",
icon: <Gauge />, icon: <Gauge />,
component: <SpeedTest />, component: SpeedTest,
}, },
{ {
path: "ipquery", 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,
}, },
], ],
}, },

View File

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

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

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

View File

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

View File

@@ -13,3 +13,21 @@ createRoot(document.getElementById('root')!).render(
<Toaster /> <Toaster />
</StrictMode> </StrictMode>
) )
// 注册 Service Worker
if ('serviceWorker' in navigator && import.meta.env.PROD) {
import('workbox-window').then(({ Workbox }) => {
const wb = new Workbox('/sw.js')
wb.addEventListener('installed', (event) => {
if (event.isUpdate) {
console.log('New service worker installed, reloading page...')
window.location.reload()
}
})
wb.register()
}).catch((error) => {
console.error('Failed to register service worker:', error)
})
}

View File

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

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

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

View File

@@ -2,13 +2,81 @@ 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 * 5 // 5 分钟
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
}
})
],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
// React核心库
if (id.includes('node_modules/react') ||
id.includes('node_modules/react-dom') ||
id.includes('node_modules/react-router-dom')) {
return 'react-vendor';
}
// Radix UI组件
if (id.includes('node_modules/@radix-ui')) {
return 'ui-vendor';
}
// 图标库
if (id.includes('node_modules/lucide-react')) {
return 'icons';
}
},
},
},
chunkSizeWarningLimit: 500,
},
}) })