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 { 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 = {
|
||||
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, 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, 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.",
|
||||
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
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 { 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
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 { 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user