feat: 添加 PWA 支持和 SEO 优化
添加 PWA manifest 文件、favicon、结构化数据、sitemap 和 robots.txt。 优化根布局和组件的国际化支持,包括服务端语言检测和防止水合闪烁。
This commit is contained in:
1
public/favicon.ico
Normal file
1
public/favicon.ico
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////3//////////////9/f3//f39//39/f/9/f3//f39////////////////////AAAAAAAAAAAAAAAA////////////////////////////8/Pz//Pz8//z8/P/8/Pz//Pz8////////////////////AAAAAAAAAAAAAAAA////////////////////////////8/Pz//Pz8//z8/P/8/Pz//Pz8////////////////////AAAAAAAAAAAAAAAA////////////////////////////5+fn/+fn5//n5+f/5+fn/+fn5////////////////////AAAAAAAAAAAAAAAA//////3+/v7////////////9/f3//f39//39/f/9/f3//f39////////////////AAAAAAAAAAAAAAAA//////3+/v7////////////9/f3//f39//39/f/9/f3//f39////////////////AAAAAAAAAAAAAAAA//////3+/v7////////////9/f3//f39//39/f/9/f3//f39////////////////AAAAAAAAAAAAAAAA//////3+/v7////////////9/f3//f39//39/f/9/f3//f39////////////////AAAAAAAAAAAAAAAA//////3+/v7////////////9/f3//f39//39/f/9/f3//f39////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////////////////wAAAP8AAAD/AAAA/wAAAP8AAAD/AAAA////////////////
|
||||||
12
public/icon.svg
Normal file
12
public/icon.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||||
|
<rect width="128" height="128" rx="28" fill="#3b82f6"/>
|
||||||
|
<rect x="16" y="16" width="40" height="40" rx="8" fill="white" opacity="0.9"/>
|
||||||
|
<rect x="72" y="16" width="40" height="40" rx="8" fill="white" opacity="0.7"/>
|
||||||
|
<rect x="16" y="72" width="40" height="40" rx="8" fill="white" opacity="0.7"/>
|
||||||
|
<rect x="72" y="72" width="40" height="40" rx="8" fill="white" opacity="0.5"/>
|
||||||
|
<path d="M30 32 L42 32 L42 42 L30 42 Z" fill="#3b82f6"/>
|
||||||
|
<circle cx="92" cy="36" r="6" fill="#3b82f6"/>
|
||||||
|
<rect x="32" y="82" width="16" height="16" rx="4" fill="#3b82f6"/>
|
||||||
|
<path d="M85 78 L99 78 L99 86 L85 86 Z" fill="#3b82f6"/>
|
||||||
|
<path d="M85 90 L99 90 L99 98 L85 98 Z" fill="#3b82f6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 759 B |
75
public/manifest.json
Normal file
75
public/manifest.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "Mini Game AI - AI-Powered Tools for Game Developers",
|
||||||
|
"short_name": "Mini Game AI",
|
||||||
|
"description": "Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, texture atlas generation, and more.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#09090b",
|
||||||
|
"theme_color": "#3b82f6",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "utilities", "developer"],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/screenshot-desktop.png",
|
||||||
|
"sizes": "1280x720",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/screenshot-mobile.png",
|
||||||
|
"sizes": "750x1334",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
67
src/app/(dashboard)/tools/texture-atlas/layout.tsx
Normal file
67
src/app/(dashboard)/tools/texture-atlas/layout.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const headersList = await headers();
|
||||||
|
const acceptLanguage = headersList.get("accept-language") || "";
|
||||||
|
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
en: "Texture Atlas Generator - Create Sprite Sheets Online",
|
||||||
|
zh: "纹理图集生成器 - 在线创建精灵图",
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptions = {
|
||||||
|
en: "Generate texture atlases and sprite sheets for game development. Pack multiple sprites into a single texture atlas with JSON data export. All processing happens locally in your browser.",
|
||||||
|
zh: "为游戏开发生成纹理图集和精灵图。将多个精灵打包到单个纹理图集中,支持导出 JSON 数据。所有处理都在您的浏览器本地进行。",
|
||||||
|
};
|
||||||
|
|
||||||
|
const keywords = {
|
||||||
|
en: [
|
||||||
|
"texture atlas",
|
||||||
|
"sprite sheet",
|
||||||
|
"game development",
|
||||||
|
"sprite packer",
|
||||||
|
"texture packing",
|
||||||
|
"game assets",
|
||||||
|
"sprite generator",
|
||||||
|
"JSON export",
|
||||||
|
"browser-based",
|
||||||
|
],
|
||||||
|
zh: [
|
||||||
|
"纹理图集",
|
||||||
|
"精灵图",
|
||||||
|
"游戏开发",
|
||||||
|
"精灵打包",
|
||||||
|
"纹理打包",
|
||||||
|
"游戏资产",
|
||||||
|
"精灵生成器",
|
||||||
|
"JSON导出",
|
||||||
|
"浏览器端",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: titles[lang],
|
||||||
|
description: descriptions[lang],
|
||||||
|
keywords: keywords[lang],
|
||||||
|
openGraph: {
|
||||||
|
title: titles[lang],
|
||||||
|
description: descriptions[lang],
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
title: titles[lang],
|
||||||
|
description: descriptions[lang],
|
||||||
|
card: "summary_large_image",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextureAtlasLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,28 +1,170 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Header } from "@/components/layout/Header";
|
import { Header } from "@/components/layout/Header";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { WebSiteStructuredData, OrganizationStructuredData } from "@/components/seo/StructuredData";
|
||||||
|
import {
|
||||||
|
LOCALE_COOKIE_NAME,
|
||||||
|
getLocaleFromAcceptLanguage,
|
||||||
|
type Locale
|
||||||
|
} from "@/lib/i18n-server";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Mini Game AI - AI-Powered Tools for Game Developers",
|
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://minigameai.com"),
|
||||||
|
title: {
|
||||||
|
default: "Mini Game AI - AI-Powered Tools for Game Developers",
|
||||||
|
template: "%s | Mini Game AI",
|
||||||
|
},
|
||||||
description:
|
description:
|
||||||
"Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, and more.",
|
"Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, texture atlas generation, and more. All processing happens locally in your browser.",
|
||||||
keywords: ["game development", "AI tools", "video processing", "image compression", "audio processing"],
|
keywords: [
|
||||||
|
"game development",
|
||||||
|
"AI tools",
|
||||||
|
"video processing",
|
||||||
|
"image compression",
|
||||||
|
"audio processing",
|
||||||
|
"texture atlas",
|
||||||
|
"sprite sheet",
|
||||||
|
"game assets",
|
||||||
|
"WebP converter",
|
||||||
|
"PNG optimizer",
|
||||||
|
"video frames",
|
||||||
|
"browser-based tools",
|
||||||
|
"privacy-first",
|
||||||
|
"no upload",
|
||||||
|
],
|
||||||
|
authors: [{ name: "Mini Game AI", url: "https://minigameai.com" }],
|
||||||
|
creator: "Mini Game AI",
|
||||||
|
publisher: "Mini Game AI",
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||||
|
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
],
|
||||||
|
shortcut: "/icon-192.png",
|
||||||
|
apple: [
|
||||||
|
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: "Mini Game AI",
|
||||||
|
statusBarStyle: "default",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
siteName: "Mini Game AI",
|
||||||
|
title: "Mini Game AI - AI-Powered Tools for Game Developers",
|
||||||
|
description:
|
||||||
|
"Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, texture atlas generation, and more. All processing happens locally in your browser.",
|
||||||
|
url: "/",
|
||||||
|
locale: "en_US",
|
||||||
|
alternateLocale: ["zh_CN"],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Mini Game AI - AI-Powered Tools for Game Developers",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "Mini Game AI - AI-Powered Tools for Game Developers",
|
||||||
|
description:
|
||||||
|
"Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, texture atlas generation, and more.",
|
||||||
|
site: "@minigameai",
|
||||||
|
creator: "@minigameai",
|
||||||
|
images: ["/twitter-image.png"],
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
},
|
||||||
|
category: "technology",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
// Detect locale from cookie or Accept-Language header
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const headersList = await headers();
|
||||||
|
|
||||||
|
let locale: Locale = "en";
|
||||||
|
|
||||||
|
// 1. Check cookie first (user's explicit preference)
|
||||||
|
const cookieLocale = cookieStore.get(LOCALE_COOKIE_NAME)?.value;
|
||||||
|
if (cookieLocale === "en" || cookieLocale === "zh") {
|
||||||
|
locale = cookieLocale;
|
||||||
|
} else {
|
||||||
|
// 2. Fall back to Accept-Language header
|
||||||
|
const acceptLanguage = headersList.get("accept-language");
|
||||||
|
locale = getLocaleFromAcceptLanguage(acceptLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang={locale} className="dark" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<WebSiteStructuredData />
|
||||||
|
<OrganizationStructuredData />
|
||||||
|
{/* Inline script to set locale before React hydrates, preventing flash */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
var COOKIE_NAME = '${LOCALE_COOKIE_NAME}';
|
||||||
|
var stored = localStorage.getItem('locale-storage');
|
||||||
|
var locale = '${locale}';
|
||||||
|
|
||||||
|
// Check localStorage first (Zustand persisted state)
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(stored);
|
||||||
|
if (parsed.state && (parsed.state.locale === 'en' || parsed.state.locale === 'zh')) {
|
||||||
|
locale = parsed.state.locale;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
} else {
|
||||||
|
// No stored preference, detect from browser
|
||||||
|
var browserLang = navigator.language.toLowerCase();
|
||||||
|
if (browserLang.indexOf('zh') === 0) {
|
||||||
|
locale = 'zh';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update HTML lang attribute immediately
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
// Set cookie for server-side detection on next request
|
||||||
|
document.cookie = COOKIE_NAME + '=' + locale + ';path=/;max-age=31536000;SameSite=Lax';
|
||||||
|
})();
|
||||||
|
`.trim(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className={cn("min-h-screen bg-background font-sans antialiased")}>
|
<body className={cn("min-h-screen bg-background font-sans antialiased")}>
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<Header />
|
<Header serverLocale={locale} />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer serverLocale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
22
src/app/robots.ts
Normal file
22
src/app/robots.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://minigameai.com";
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
disallow: ["/api/", "/_next/", "/static/"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userAgent: "Googlebot",
|
||||||
|
allow: "/",
|
||||||
|
disallow: ["/api/", "/_next/"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
host: baseUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/app/sitemap.ts
Normal file
87
src/app/sitemap.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
interface ToolPage {
|
||||||
|
path: string;
|
||||||
|
en: { title: string; description: string };
|
||||||
|
zh: { title: string; description: string };
|
||||||
|
lastModified: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools: ToolPage[] = [
|
||||||
|
{
|
||||||
|
path: "/tools/image-compress",
|
||||||
|
en: {
|
||||||
|
title: "Image Compression - Optimize Images for Web & Mobile",
|
||||||
|
description: "Optimize images for web and mobile without quality loss. Support for batch processing and format conversion including PNG, JPEG, WebP.",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: "图片压缩 - 为网页和移动端优化图片",
|
||||||
|
description: "为网页和移动端优化图片,不影响质量。支持批量处理和格式转换,包括 PNG、JPEG、WebP。",
|
||||||
|
},
|
||||||
|
lastModified: new Date("2025-01-01"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tools/video-frames",
|
||||||
|
en: {
|
||||||
|
title: "Video to Frames - Extract Frames from Videos",
|
||||||
|
description: "Extract frames from videos with precision control. Support for MP4, WebM and other video formats. Export as PNG or ZIP.",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: "视频抽帧 - 从视频中提取帧",
|
||||||
|
description: "精确控制从视频中提取帧。支持 MP4、WebM 等视频格式。导出为 PNG 或 ZIP。",
|
||||||
|
},
|
||||||
|
lastModified: new Date("2025-01-01"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tools/audio-compress",
|
||||||
|
en: {
|
||||||
|
title: "Audio Compression - Compress & Convert Audio Files",
|
||||||
|
description: "Compress and convert audio files with ease. Support for MP3, WAV, OGG and more. Adjustable quality settings.",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: "音频压缩 - 压缩并转换音频文件",
|
||||||
|
description: "轻松压缩和转换音频文件。支持 MP3、WAV、OGG 等格式。可调节质量设置。",
|
||||||
|
},
|
||||||
|
lastModified: new Date("2025-01-01"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/tools/texture-atlas",
|
||||||
|
en: {
|
||||||
|
title: "Texture Atlas Generator - Create Sprite Sheets Online",
|
||||||
|
description: "Generate texture atlases and sprite sheets for game development. Pack multiple sprites into a single texture atlas with JSON data export.",
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: "纹理图集生成器 - 在线创建精灵图",
|
||||||
|
description: "为游戏开发生成纹理图集和精灵图。将多个精灵打包到单个纹理图集中,支持导出 JSON 数据。",
|
||||||
|
},
|
||||||
|
lastModified: new Date("2025-01-01"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://minigameai.com";
|
||||||
|
|
||||||
|
const staticPages: MetadataRoute.Sitemap = [
|
||||||
|
{
|
||||||
|
url: baseUrl,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/tools`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolPages: MetadataRoute.Sitemap = tools.map((tool) => ({
|
||||||
|
url: `${baseUrl}${tool.path}`,
|
||||||
|
lastModified: tool.lastModified,
|
||||||
|
changeFrequency: "weekly" as const,
|
||||||
|
priority: 0.8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...staticPages, ...toolPages];
|
||||||
|
}
|
||||||
@@ -3,30 +3,26 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
import { useSafeTranslation, type Locale } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Footer() {
|
interface FooterProps {
|
||||||
|
serverLocale?: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({ serverLocale }: FooterProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mounted, setMounted] = useState(false);
|
const { t, locale, mounted } = useSafeTranslation(serverLocale);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
const getT = (key: string, params?: Record<string, string | number>) => {
|
|
||||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
|
||||||
return t(key, params);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolLinks = useMemo(
|
const toolLinks = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
||||||
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress" },
|
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress" },
|
||||||
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[mounted]
|
[mounted, locale]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if we're in the dashboard area (tools pages)
|
// Check if we're in the dashboard area (tools pages)
|
||||||
@@ -44,22 +40,22 @@ export function Footer() {
|
|||||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary text-primary-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.1)]">
|
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary text-primary-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.1)]">
|
||||||
<Sparkles className="h-5 w-5" />
|
<Sparkles className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base font-semibold tracking-tight">{getT("common.appName")}</span>
|
<span className="text-base font-semibold tracking-tight">{t("common.appName")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<p className="mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
|
<p className="mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
|
||||||
{getT("footer.tagline")}
|
{t("footer.tagline")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-6 text-xs text-muted-foreground">
|
<p className="mt-6 text-xs text-muted-foreground">
|
||||||
{getT("footer.note")}
|
{t("footer.note")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-8">
|
<div className="grid grid-cols-2 gap-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{getT("footer.sections.tools")}
|
{t("footer.sections.tools")}
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-4 space-y-2 text-sm">
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
{toolLinks.map((link) => (
|
{toolLinks.map((link) => (
|
||||||
@@ -74,12 +70,12 @@ export function Footer() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{getT("footer.sections.company")}
|
{t("footer.sections.company")}
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-4 space-y-2 text-sm">
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/" className="text-muted-foreground hover:text-foreground">
|
<Link href="/" className="text-muted-foreground hover:text-foreground">
|
||||||
{getT("footer.links.home")}
|
{t("footer.links.home")}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -90,7 +86,7 @@ export function Footer() {
|
|||||||
<div className="mt-10 border-t border-white/5 pt-6">
|
<div className="mt-10 border-t border-white/5 pt-6">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
© {new Date().getFullYear()} {getT("common.appName")}. {getT("footer.copyright")}
|
© {new Date().getFullYear()} {t("common.appName")}. {t("footer.copyright")}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="https://beian.miit.gov.cn"
|
href="https://beian.miit.gov.cn"
|
||||||
|
|||||||
@@ -7,43 +7,29 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import { ArrowRight, Menu, X, Sparkles } from "lucide-react";
|
import { ArrowRight, Menu, X, Sparkles } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
import { useSafeTranslation, type Locale } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Header() {
|
interface HeaderProps {
|
||||||
|
serverLocale?: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ serverLocale }: HeaderProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const { t, locale, mounted } = useSafeTranslation(serverLocale);
|
||||||
const { t, locale, setLocale } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
|
|
||||||
// Auto detect language on first mount if not manually set
|
|
||||||
const stored = localStorage.getItem("locale-storage");
|
|
||||||
if (!stored) {
|
|
||||||
const lang = navigator.language.toLowerCase();
|
|
||||||
if (lang.includes("zh")) setLocale("zh");
|
|
||||||
}
|
|
||||||
}, [setLocale]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
document.documentElement.lang = locale;
|
document.documentElement.lang = locale;
|
||||||
}, [locale, mounted]);
|
}, [locale, mounted]);
|
||||||
|
|
||||||
// Prevent hydration mismatch by rendering a stable version initially
|
|
||||||
const displayT = (key: string, params?: Record<string, string | number>) => {
|
|
||||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
|
||||||
return t(key, params);
|
|
||||||
};
|
|
||||||
|
|
||||||
const navItems = useMemo(
|
const navItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ name: displayT("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
||||||
{ name: displayT("sidebar.imageCompression"), href: "/tools/image-compress" },
|
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress" },
|
||||||
{ name: displayT("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
||||||
{ name: displayT("sidebar.textureAtlas"), href: "/tools/texture-atlas" },
|
{ name: t("sidebar.textureAtlas"), href: "/tools/texture-atlas" },
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[mounted, locale]
|
[mounted, locale]
|
||||||
@@ -57,7 +43,7 @@ export function Header() {
|
|||||||
<Sparkles className="h-5 w-5" />
|
<Sparkles className="h-5 w-5" />
|
||||||
<span className="pointer-events-none absolute inset-0 rounded-xl opacity-0 transition-opacity duration-500 group-hover:opacity-100 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.35),transparent_60%)]" />
|
<span className="pointer-events-none absolute inset-0 rounded-xl opacity-0 transition-opacity duration-500 group-hover:opacity-100 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.35),transparent_60%)]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base font-semibold tracking-tight">{displayT("common.appName")}</span>
|
<span className="text-base font-semibold tracking-tight">{t("common.appName")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
@@ -81,10 +67,10 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
{mounted && <LanguageSwitcher />}
|
<LanguageSwitcher serverLocale={serverLocale} />
|
||||||
<Button asChild size="sm" className="rounded-full px-4">
|
<Button asChild size="sm" className="rounded-full px-4">
|
||||||
<Link href="/tools/image-compress">
|
<Link href="/tools/image-compress">
|
||||||
{displayT("common.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
|
{t("common.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +78,7 @@ export function Header() {
|
|||||||
<button
|
<button
|
||||||
className="md:hidden rounded-full p-2 hover:bg-white/5"
|
className="md:hidden rounded-full p-2 hover:bg-white/5"
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
aria-label={displayT("common.toggleMenu")}
|
aria-label={t("common.toggleMenu")}
|
||||||
>
|
>
|
||||||
{isMobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
{isMobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -127,10 +113,10 @@ export function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between">
|
||||||
{mounted && <LanguageSwitcher />}
|
<LanguageSwitcher serverLocale={serverLocale} />
|
||||||
<Button asChild size="sm" className="rounded-full px-4">
|
<Button asChild size="sm" className="rounded-full px-4">
|
||||||
<Link href="/tools/image-compress" onClick={() => setIsMobileMenuOpen(false)}>
|
<Link href="/tools/image-compress" onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
{displayT("common.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
|
{t("common.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useTranslation, type Locale } from "@/lib/i18n";
|
import { useSafeTranslation, type Locale } from "@/lib/i18n";
|
||||||
import { Check, Globe } from "lucide-react";
|
import { Check, Globe } from "lucide-react";
|
||||||
|
|
||||||
export function LanguageSwitcher() {
|
interface LanguageSwitcherProps {
|
||||||
const { locale, setLocale, locales } = useTranslation();
|
serverLocale?: Locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSwitcher({ serverLocale }: LanguageSwitcherProps) {
|
||||||
|
const { locale, setLocale, locales } = useSafeTranslation(serverLocale);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleLocaleChange = (newLocale: Locale) => {
|
const handleLocaleChange = (newLocale: Locale) => {
|
||||||
|
|||||||
157
src/components/seo/StructuredData.tsx
Normal file
157
src/components/seo/StructuredData.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
interface StructuredDataProps {
|
||||||
|
type: "WebSite" | "Organization" | "SoftwareApplication" | "WebPage";
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StructuredData({ type, data }: StructuredDataProps) {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://minigameai.com";
|
||||||
|
|
||||||
|
const baseData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": type,
|
||||||
|
url: baseUrl,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(baseData) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSiteProps {
|
||||||
|
lang?: "en" | "zh";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebSiteStructuredData({ lang = "en" }: WebSiteProps) {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://minigameai.com";
|
||||||
|
|
||||||
|
const content = {
|
||||||
|
en: {
|
||||||
|
name: "Mini Game AI",
|
||||||
|
alternateName: "MiniGameAI",
|
||||||
|
description:
|
||||||
|
"Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, texture atlas generation, and more.",
|
||||||
|
keywords:
|
||||||
|
"game development, AI tools, video processing, image compression, audio processing, texture atlas",
|
||||||
|
inLanguage: "en-US",
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: {
|
||||||
|
"@type": "EntryPoint",
|
||||||
|
urlTemplate: `${baseUrl}/search?q={search_term_string}`,
|
||||||
|
},
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
name: "Mini Game AI",
|
||||||
|
alternateName: "迷你游戏AI",
|
||||||
|
description:
|
||||||
|
"用 AI 驱动的工具改变游戏开发流程。视频抽帧、图片压缩、音频处理、纹理图集生成等。",
|
||||||
|
keywords:
|
||||||
|
"游戏开发, AI工具, 视频处理, 图片压缩, 音频处理, 纹理图集",
|
||||||
|
inLanguage: "zh-CN",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
url: baseUrl,
|
||||||
|
...content[lang],
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationStructuredData() {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://minigameai.com";
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Mini Game AI",
|
||||||
|
url: baseUrl,
|
||||||
|
logo: `${baseUrl}/icon-512.png`,
|
||||||
|
description:
|
||||||
|
"AI-powered tools for game developers. Transform your workflow with browser-based tools for video, image, and audio processing.",
|
||||||
|
sameAs: [] as string[],
|
||||||
|
contactPoint: {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
contactType: "customer service",
|
||||||
|
url: `${baseUrl}/contact`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SoftwareApplicationProps {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
category: string;
|
||||||
|
operatingSystem: string;
|
||||||
|
offersPrice?: string;
|
||||||
|
offersCurrency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoftwareApplicationStructuredData({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
category,
|
||||||
|
operatingSystem = "Any (Browser-based)",
|
||||||
|
offersPrice = "0",
|
||||||
|
offersCurrency = "USD",
|
||||||
|
}: SoftwareApplicationProps) {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://minigameai.com";
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
url: `${baseUrl}${url}`,
|
||||||
|
applicationCategory: category,
|
||||||
|
operatingSystem,
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: offersPrice,
|
||||||
|
priceCurrency: offersCurrency,
|
||||||
|
},
|
||||||
|
featureList: [
|
||||||
|
"Browser-based processing",
|
||||||
|
"No file uploads required",
|
||||||
|
"Privacy-first design",
|
||||||
|
"Batch processing support",
|
||||||
|
],
|
||||||
|
browserRequirements: "Requires JavaScript. Requires HTML5.",
|
||||||
|
softwareVersion: "1.0",
|
||||||
|
author: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "Mini Game AI",
|
||||||
|
url: baseUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/lib/i18n-server.ts
Normal file
64
src/lib/i18n-server.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Server-side i18n utilities
|
||||||
|
* This file can be safely imported in Server Components
|
||||||
|
*/
|
||||||
|
import en from "@/locales/en.json";
|
||||||
|
import zh from "@/locales/zh.json";
|
||||||
|
|
||||||
|
export type Locale = "en" | "zh";
|
||||||
|
export type LocaleMessages = typeof en;
|
||||||
|
|
||||||
|
export const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
|
||||||
|
export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||||
|
|
||||||
|
export const locales: Record<Locale, { name: string }> = {
|
||||||
|
en: { name: "English" },
|
||||||
|
zh: { name: "中文" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages: Record<Locale, LocaleMessages> = { en, zh };
|
||||||
|
|
||||||
|
function getNestedValue(obj: Record<string, unknown>, path: string): string | unknown {
|
||||||
|
return path.split(".").reduce((acc: unknown, part) => {
|
||||||
|
if (acc && typeof acc === "object" && part in (acc as Record<string, unknown>)) {
|
||||||
|
return (acc as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, obj) || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, params: Record<string, string | number | unknown> = {}): string {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||||
|
const value = params[key];
|
||||||
|
if (value === undefined || value === null) return `{{${key}}}`;
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get translations for a specific locale (server-side)
|
||||||
|
*/
|
||||||
|
export function getServerTranslations(locale: Locale = "en") {
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => {
|
||||||
|
const value = getNestedValue(messages[locale], key);
|
||||||
|
if (typeof value !== "string") return value as string;
|
||||||
|
if (!params || Object.keys(params).length === 0) return value;
|
||||||
|
return interpolate(value, params);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect locale from Accept-Language header
|
||||||
|
*/
|
||||||
|
export function getLocaleFromAcceptLanguage(acceptLanguage: string | null): Locale {
|
||||||
|
if (!acceptLanguage) return "en";
|
||||||
|
const languages = acceptLanguage.split(",").map(lang => lang.split(";")[0].trim().toLowerCase());
|
||||||
|
for (const lang of languages) {
|
||||||
|
if (lang.startsWith("zh")) return "zh";
|
||||||
|
if (lang.startsWith("en")) return "en";
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
101
src/lib/i18n.ts
101
src/lib/i18n.ts
@@ -1,17 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist, createJSONStorage } from "zustand/middleware";
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import en from "@/locales/en.json";
|
import en from "@/locales/en.json";
|
||||||
import zh from "@/locales/zh.json";
|
import zh from "@/locales/zh.json";
|
||||||
|
|
||||||
export type Locale = "en" | "zh";
|
// Re-export server types for convenience
|
||||||
export type LocaleMessages = typeof en;
|
export type { Locale, LocaleMessages } from "./i18n-server";
|
||||||
|
export {
|
||||||
|
LOCALE_COOKIE_NAME,
|
||||||
|
LOCALE_COOKIE_MAX_AGE,
|
||||||
|
locales,
|
||||||
|
getServerTranslations,
|
||||||
|
getLocaleFromAcceptLanguage
|
||||||
|
} from "./i18n-server";
|
||||||
|
|
||||||
export const locales: Record<Locale, { name: string }> = {
|
import type { Locale } from "./i18n-server";
|
||||||
en: { name: "English" },
|
import { LOCALE_COOKIE_NAME, LOCALE_COOKIE_MAX_AGE, locales, getServerTranslations } from "./i18n-server";
|
||||||
zh: { name: "中文" },
|
|
||||||
};
|
|
||||||
|
|
||||||
|
type LocaleMessages = typeof en;
|
||||||
const messages: Record<Locale, LocaleMessages> = { en, zh };
|
const messages: Record<Locale, LocaleMessages> = { en, zh };
|
||||||
|
|
||||||
interface I18nState {
|
interface I18nState {
|
||||||
@@ -37,11 +45,48 @@ function interpolate(template: string, params: Record<string, string | number |
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the initial locale from cookie or browser language
|
||||||
|
* This runs on client side only
|
||||||
|
*/
|
||||||
|
function getInitialLocale(): Locale {
|
||||||
|
if (typeof window === "undefined") return "en";
|
||||||
|
|
||||||
|
// 1. Check cookie first (user's explicit preference)
|
||||||
|
const cookieMatch = document.cookie.match(new RegExp(`${LOCALE_COOKIE_NAME}=([^;]+)`));
|
||||||
|
if (cookieMatch) {
|
||||||
|
const cookieLocale = cookieMatch[1] as Locale;
|
||||||
|
if (cookieLocale === "en" || cookieLocale === "zh") {
|
||||||
|
return cookieLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check browser language
|
||||||
|
const browserLang = navigator.language.toLowerCase();
|
||||||
|
if (browserLang.startsWith("zh")) {
|
||||||
|
return "zh";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set locale cookie for server-side detection
|
||||||
|
*/
|
||||||
|
export function setLocaleCookie(locale: Locale) {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.cookie = `${LOCALE_COOKIE_NAME}=${locale};path=/;max-age=${LOCALE_COOKIE_MAX_AGE};SameSite=Lax`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useI18nStore = create<I18nState>()(
|
export const useI18nStore = create<I18nState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
locale: "en", // Default to en for SSR/Hydration
|
locale: getInitialLocale(),
|
||||||
setLocale: (locale) => set({ locale }),
|
setLocale: (locale) => {
|
||||||
|
setLocaleCookie(locale);
|
||||||
|
set({ locale });
|
||||||
|
},
|
||||||
|
|
||||||
t: (key, params) => {
|
t: (key, params) => {
|
||||||
const { locale } = get();
|
const { locale } = get();
|
||||||
@@ -54,6 +99,12 @@ export const useI18nStore = create<I18nState>()(
|
|||||||
{
|
{
|
||||||
name: "locale-storage",
|
name: "locale-storage",
|
||||||
storage: createJSONStorage(() => localStorage),
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// After rehydration, sync cookie with stored locale
|
||||||
|
if (state?.locale) {
|
||||||
|
setLocaleCookie(state.locale);
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -78,47 +129,37 @@ export function useTranslation() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SSR-safe translation hook that prevents hydration mismatches.
|
* SSR-safe translation hook that prevents hydration mismatches.
|
||||||
* Use this in client components that are rendered on the server.
|
* Uses server-detected locale during SSR to match initial render.
|
||||||
* Returns a stable translation during SSR and switches to client locale after hydration.
|
|
||||||
*/
|
*/
|
||||||
export function useSafeTranslation() {
|
export function useSafeTranslation(serverLocale?: Locale) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
const { locale, setLocale, t, plural, locales } = useTranslation();
|
const { locale, setLocale, t, plural, locales } = useTranslation();
|
||||||
|
|
||||||
// Use English during SSR, client locale after hydration
|
// Use server locale during SSR, client locale after hydration
|
||||||
|
const effectiveLocale = mounted ? locale : (serverLocale || locale);
|
||||||
|
|
||||||
const safeT: typeof t = (key, params) => {
|
const safeT: typeof t = (key, params) => {
|
||||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
if (!mounted && serverLocale) {
|
||||||
|
return getServerTranslations(serverLocale).t(key, params);
|
||||||
|
}
|
||||||
return t(key, params);
|
return t(key, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const safePlural: typeof plural = (key, count) => {
|
const safePlural: typeof plural = (key, count) => {
|
||||||
if (!mounted) {
|
if (!mounted && serverLocale) {
|
||||||
const suffix = count === 1 ? "_one" : "_other";
|
const suffix = count === 1 ? "_one" : "_other";
|
||||||
return getServerTranslations("en").t(`${key}${suffix}`, { count });
|
return getServerTranslations(serverLocale).t(`${key}${suffix}`, { count });
|
||||||
}
|
}
|
||||||
return plural(key, count);
|
return plural(key, count);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locale,
|
locale: effectiveLocale,
|
||||||
setLocale,
|
setLocale,
|
||||||
t: safeT,
|
t: safeT,
|
||||||
plural: safePlural,
|
plural: safePlural,
|
||||||
locales,
|
locales,
|
||||||
mounted, // Expose mounted state for conditional rendering if needed
|
mounted,
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for SSR
|
|
||||||
export function getServerTranslations(locale: Locale = "en") {
|
|
||||||
return {
|
|
||||||
locale,
|
|
||||||
t: (key: string, params?: Record<string, string | number>) => {
|
|
||||||
const value = getNestedValue(messages[locale], key);
|
|
||||||
if (typeof value !== "string") return value as string;
|
|
||||||
if (!params || Object.keys(params).length === 0) return value;
|
|
||||||
return interpolate(value, params);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user