feat: 添加 PWA 支持和 SEO 优化

添加 PWA manifest 文件、favicon、结构化数据、sitemap 和 robots.txt。
优化根布局和组件的国际化支持,包括服务端语言检测和防止水合闪烁。
This commit is contained in:
2026-01-31 20:26:55 +08:00
parent b29de1dd80
commit a44a3f6a0d
13 changed files with 748 additions and 94 deletions

1
public/favicon.ico Normal file
View 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
View 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
View 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"
}
]
}

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

View File

@@ -1,28 +1,170 @@
import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import "./globals.css";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
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 = {
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:
"Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, and more.",
keywords: ["game development", "AI tools", "video processing", "image compression", "audio processing"],
"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",
"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,
}: Readonly<{
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 (
<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")}>
<div className="flex min-h-screen flex-col">
<Header />
<Header serverLocale={locale} />
<main className="flex-1">{children}</main>
<Footer />
<Footer serverLocale={locale} />
</div>
</body>
</html>

22
src/app/robots.ts Normal file
View 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
View 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];
}

View File

@@ -3,30 +3,26 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { useMemo } from "react";
import { useSafeTranslation, type Locale } from "@/lib/i18n";
import { cn } from "@/lib/utils";
export function Footer() {
interface FooterProps {
serverLocale?: Locale;
}
export function Footer({ serverLocale }: FooterProps) {
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
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 { t, locale, mounted } = useSafeTranslation(serverLocale);
const toolLinks = useMemo(
() => [
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress" },
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress" },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[mounted]
[mounted, locale]
);
// 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)]">
<Sparkles className="h-5 w-5" />
</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>
<p className="mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
{getT("footer.tagline")}
{t("footer.tagline")}
</p>
<p className="mt-6 text-xs text-muted-foreground">
{getT("footer.note")}
{t("footer.note")}
</p>
</div>
<div className="grid grid-cols-2 gap-8">
<div>
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{getT("footer.sections.tools")}
{t("footer.sections.tools")}
</div>
<ul className="mt-4 space-y-2 text-sm">
{toolLinks.map((link) => (
@@ -74,12 +70,12 @@ export function Footer() {
<div>
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{getT("footer.sections.company")}
{t("footer.sections.company")}
</div>
<ul className="mt-4 space-y-2 text-sm">
<li>
<Link href="/" className="text-muted-foreground hover:text-foreground">
{getT("footer.links.home")}
{t("footer.links.home")}
</Link>
</li>
</ul>
@@ -90,7 +86,7 @@ export function Footer() {
<div className="mt-10 border-t border-white/5 pt-6">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<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>
<Link
href="https://beian.miit.gov.cn"

View File

@@ -7,43 +7,29 @@ import { motion, AnimatePresence } from "framer-motion";
import { ArrowRight, Menu, X, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { useSafeTranslation, type Locale } from "@/lib/i18n";
import { cn } from "@/lib/utils";
export function Header() {
interface HeaderProps {
serverLocale?: Locale;
}
export function Header({ serverLocale }: HeaderProps) {
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [mounted, setMounted] = useState(false);
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]);
const { t, locale, mounted } = useSafeTranslation(serverLocale);
useEffect(() => {
if (!mounted) return;
document.documentElement.lang = locale;
}, [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(
() => [
{ name: displayT("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: displayT("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: displayT("sidebar.audioCompression"), href: "/tools/audio-compress" },
{ name: displayT("sidebar.textureAtlas"), href: "/tools/texture-atlas" },
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress" },
{ name: t("sidebar.textureAtlas"), href: "/tools/texture-atlas" },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[mounted, locale]
@@ -57,7 +43,7 @@ export function Header() {
<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>
<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>
<div className="hidden md:flex items-center gap-1">
@@ -81,10 +67,10 @@ export function Header() {
</div>
<div className="hidden md:flex items-center gap-2">
{mounted && <LanguageSwitcher />}
<LanguageSwitcher serverLocale={serverLocale} />
<Button asChild size="sm" className="rounded-full px-4">
<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>
</Button>
</div>
@@ -92,7 +78,7 @@ export function Header() {
<button
className="md:hidden rounded-full p-2 hover:bg-white/5"
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" />}
</button>
@@ -127,10 +113,10 @@ export function Header() {
</div>
<div className="mt-4 flex items-center justify-between">
{mounted && <LanguageSwitcher />}
<LanguageSwitcher serverLocale={serverLocale} />
<Button asChild size="sm" className="rounded-full px-4">
<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>
</Button>
</div>

View File

@@ -8,11 +8,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} 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";
export function LanguageSwitcher() {
const { locale, setLocale, locales } = useTranslation();
interface LanguageSwitcherProps {
serverLocale?: Locale;
}
export function LanguageSwitcher({ serverLocale }: LanguageSwitcherProps) {
const { locale, setLocale, locales } = useSafeTranslation(serverLocale);
const [open, setOpen] = useState(false);
const handleLocaleChange = (newLocale: Locale) => {

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

View File

@@ -1,17 +1,25 @@
"use client";
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { useState, useEffect } from "react";
import en from "@/locales/en.json";
import zh from "@/locales/zh.json";
export type Locale = "en" | "zh";
export type LocaleMessages = typeof en;
// Re-export server types for convenience
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 }> = {
en: { name: "English" },
zh: { name: "中文" },
};
import type { Locale } from "./i18n-server";
import { LOCALE_COOKIE_NAME, LOCALE_COOKIE_MAX_AGE, locales, getServerTranslations } from "./i18n-server";
type LocaleMessages = typeof en;
const messages: Record<Locale, LocaleMessages> = { en, zh };
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>()(
persist(
(set, get) => ({
locale: "en", // Default to en for SSR/Hydration
setLocale: (locale) => set({ locale }),
locale: getInitialLocale(),
setLocale: (locale) => {
setLocaleCookie(locale);
set({ locale });
},
t: (key, params) => {
const { locale } = get();
@@ -54,6 +99,12 @@ export const useI18nStore = create<I18nState>()(
{
name: "locale-storage",
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.
* Use this in client components that are rendered on the server.
* Returns a stable translation during SSR and switches to client locale after hydration.
* Uses server-detected locale during SSR to match initial render.
*/
export function useSafeTranslation() {
export function useSafeTranslation(serverLocale?: Locale) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
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) => {
if (!mounted) return getServerTranslations("en").t(key, params);
if (!mounted && serverLocale) {
return getServerTranslations(serverLocale).t(key, params);
}
return t(key, params);
};
const safePlural: typeof plural = (key, count) => {
if (!mounted) {
if (!mounted && serverLocale) {
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 {
locale,
locale: effectiveLocale,
setLocale,
t: safeT,
plural: safePlural,
locales,
mounted, // Expose mounted state for conditional rendering if needed
};
}
// 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);
},
mounted,
};
}