- {getT("footer.sections.company")}
+ {t("footer.sections.company")}
- © {new Date().getFullYear()} {getT("common.appName")}. {getT("footer.copyright")}
+ © {new Date().getFullYear()} {t("common.appName")}. {t("footer.copyright")}
{
- 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
) => {
- 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() {
- {displayT("common.appName")}
+ {t("common.appName")}
@@ -81,10 +67,10 @@ export function Header() {
- {mounted &&
}
+
@@ -92,7 +78,7 @@ export function Header() {
@@ -127,10 +113,10 @@ export function Header() {
- {mounted &&
}
+
diff --git a/src/components/layout/LanguageSwitcher.tsx b/src/components/layout/LanguageSwitcher.tsx
index d070f14..caa5898 100644
--- a/src/components/layout/LanguageSwitcher.tsx
+++ b/src/components/layout/LanguageSwitcher.tsx
@@ -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) => {
diff --git a/src/components/seo/StructuredData.tsx b/src/components/seo/StructuredData.tsx
new file mode 100644
index 0000000..ba8a7cd
--- /dev/null
+++ b/src/components/seo/StructuredData.tsx
@@ -0,0 +1,157 @@
+interface StructuredDataProps {
+ type: "WebSite" | "Organization" | "SoftwareApplication" | "WebPage";
+ data: Record
;
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/src/lib/i18n-server.ts b/src/lib/i18n-server.ts
new file mode 100644
index 0000000..368fdcd
--- /dev/null
+++ b/src/lib/i18n-server.ts
@@ -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 = {
+ en: { name: "English" },
+ zh: { name: "中文" },
+};
+
+const messages: Record = { en, zh };
+
+function getNestedValue(obj: Record, path: string): string | unknown {
+ return path.split(".").reduce((acc: unknown, part) => {
+ if (acc && typeof acc === "object" && part in (acc as Record)) {
+ return (acc as Record)[part];
+ }
+ return undefined;
+ }, obj) || path;
+}
+
+function interpolate(template: string, params: Record = {}): 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) => {
+ 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";
+}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
index 6cc27db..df90e5f 100644
--- a/src/lib/i18n.ts
+++ b/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 = {
- 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 = { en, zh };
interface I18nState {
@@ -37,11 +45,48 @@ function interpolate(template: string, params: Record()(
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()(
{
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) => {
- 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,
};
}