feat: 优化翻译函数支持参数传递

This commit is contained in:
2026-01-24 17:06:40 +08:00
parent b7402edf6a
commit fc29ec880c
11 changed files with 653 additions and 686 deletions

View File

@@ -83,9 +83,9 @@ export default function AudioCompressPage() {
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
const getT = (key: string, params?: Record<string, string | number>) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =

View File

@@ -57,9 +57,9 @@ export default function ImageCompressPage() {
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
const getT = (key: string, params?: Record<string, string | number>) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =

View File

@@ -70,9 +70,9 @@ export default function VideoFramesPage() {
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
const getT = (key: string, params?: Record<string, string | number>) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =

View File

@@ -106,8 +106,21 @@
);
background-size: 1000px 100%;
}
/* Subtle film grain (Apple-ish texture) */
.noise-overlay {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E");
background-size: 180px 180px;
}
/* Gentle top fade used by hero backgrounds */
.mask-fade-y {
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 18%, black 82%, transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 18%, black 82%, transparent 100%);
}
}
@layer utilities {
.text-balance {
text-wrap: balance;

View File

@@ -1,15 +1,13 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { cn } from "@/lib/utils";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
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.",
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"],
};
@@ -20,7 +18,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="dark">
<body className={cn("min-h-screen bg-background font-sans antialiased", inter.variable)}>
<body className={cn("min-h-screen bg-background font-sans antialiased")}>
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>

View File

@@ -1,435 +1,444 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import {
ArrowRight,
Video,
Image,
ChevronDown,
Image as ImageIcon,
Music,
ShieldCheck,
Sparkles,
Video,
Zap,
Shield,
Users,
Check,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { useState, useEffect } from "react";
function useFeatures() {
type TFn = (key: string, params?: Record<string, string | number>) => string;
function useStableT(): TFn {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
useEffect(() => setMounted(true), []);
return [
return useMemo(() => {
const serverT = getServerTranslations("en").t;
return (key: string, params?: Record<string, string | number>) => {
if (!mounted) return serverT(key, params);
return t(key, params);
};
}, [mounted, t]);
}
function SectionHeader({ kicker, title, description }: { kicker?: string; title: string; description?: string }) {
return (
<div className="mx-auto max-w-3xl text-center">
{kicker ? (
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs font-medium tracking-wide text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
<span>{kicker}</span>
</div>
) : null}
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
{title}
</h2>
{description ? (
<p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">
{description}
</p>
) : null}
</div>
);
}
function BackgroundAuras({ reduceMotion }: { reduceMotion: boolean }) {
return (
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,hsl(var(--primary)/0.25),transparent_45%),radial-gradient(circle_at_80%_0%,hsl(var(--accent)/0.16),transparent_40%),radial-gradient(circle_at_50%_80%,rgba(255,255,255,0.07),transparent_55%)]" />
<motion.div
aria-hidden="true"
className="absolute -top-24 left-1/2 h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle_at_40%_30%,rgba(255,255,255,0.18),transparent_55%)] blur-3xl"
animate={reduceMotion ? undefined : { y: [0, 26, 0], scale: [1, 1.03, 1] }}
transition={reduceMotion ? undefined : { duration: 12, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
aria-hidden="true"
className="absolute -bottom-40 left-[-10%] h-[520px] w-[520px] rounded-full bg-[radial-gradient(circle_at_50%_50%,hsl(var(--primary)/0.18),transparent_60%)] blur-3xl"
animate={reduceMotion ? undefined : { x: [0, 80, 0], y: [0, -30, 0] }}
transition={reduceMotion ? undefined : { duration: 18, repeat: Infinity, ease: "easeInOut" }}
/>
<div className="absolute inset-0 noise-overlay opacity-[0.09] mix-blend-overlay mask-fade-y" />
</div>
);
}
function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
return (
<section className="relative overflow-hidden">
<BackgroundAuras reduceMotion={reduceMotion} />
<div className="container py-20 sm:py-24 md:py-28">
<motion.div
initial={reduceMotion ? undefined : { opacity: 0, y: 16 }}
animate={reduceMotion ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
className="mx-auto max-w-4xl text-center"
>
<div className="mb-4 text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
{t("home.hero.kicker")}
</div>
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl md:text-6xl">
{t("home.hero.title")}
</h1>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-muted-foreground sm:text-lg">
{t("home.hero.description")}
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Button asChild size="lg" className="rounded-full px-7">
<Link href="/tools/image-compress">
{t("home.hero.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
<Button asChild size="lg" variant="outline" className="rounded-full border-white/10 bg-white/[0.02] px-7 hover:bg-white/[0.04]">
<a href="#tools">
{t("home.hero.secondaryCta")} <ChevronDown className="ml-1 h-4 w-4" />
</a>
</Button>
</div>
<p className="mt-6 text-xs text-muted-foreground">{t("home.hero.note")}</p>
</motion.div>
<motion.div
initial={reduceMotion ? undefined : { opacity: 0, y: 20 }}
animate={reduceMotion ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
className="mx-auto mt-14 max-w-6xl"
>
<div className="relative overflow-hidden rounded-3xl border border-white/10 bg-white/[0.03] shadow-[0_1px_0_0_rgba(255,255,255,0.06)_inset]">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(255,255,255,0.08),transparent_55%),radial-gradient(circle_at_80%_70%,hsl(var(--primary)/0.14),transparent_55%)]" />
<div className="relative p-6 sm:p-8">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-red-500/70" />
<span className="h-2 w-2 rounded-full bg-yellow-500/70" />
<span className="h-2 w-2 rounded-full bg-green-500/70" />
<span className="ml-3">{t("home.hero.previewTitle")}</span>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{[
{
icon: Video,
title: getT("home.tools.videoToFrames.title"),
description: getT("home.tools.videoToFrames.description"),
title: t("home.tools.videoToFrames.title"),
description: t("home.tools.videoToFrames.description"),
href: "/tools/video-frames",
tint: "from-sky-500/20",
},
{
icon: Image,
title: getT("home.tools.imageCompression.title"),
description: getT("home.tools.imageCompression.description"),
icon: ImageIcon,
title: t("home.tools.imageCompression.title"),
description: t("home.tools.imageCompression.description"),
href: "/tools/image-compress",
tint: "from-violet-500/20",
},
{
icon: Music,
title: getT("home.tools.audioCompression.title"),
description: getT("home.tools.audioCompression.description"),
title: t("home.tools.audioCompression.title"),
description: t("home.tools.audioCompression.description"),
href: "/tools/audio-compress",
tint: "from-emerald-500/20",
},
].map((tool) => (
<motion.div
key={tool.href}
whileHover={reduceMotion ? undefined : { y: -6 }}
transition={{ type: "spring", stiffness: 260, damping: 18 }}
>
<Link
href={tool.href}
className="group block h-full rounded-2xl border border-white/10 bg-black/20 p-5 backdrop-blur-xl transition-colors hover:bg-black/25"
>
<div className="relative">
<div
className={cn(
"absolute -inset-5 rounded-3xl bg-gradient-to-br opacity-0 blur-2xl transition-opacity duration-500 group-hover:opacity-100",
tool.tint
)}
/>
<div className="relative flex items-start gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-white/[0.06] text-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
<tool.icon className="h-5 w-5" />
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold tracking-tight">{tool.title}</div>
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{tool.description}
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<span>{t("common.tryNow")}</span>
<ArrowRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5" />
</div>
</Link>
</motion.div>
))}
</div>
<div className="mt-10 grid grid-cols-3 gap-6 text-center">
{[
{ value: "10K+", label: t("home.hero.stats.developers") },
{ value: "1M+", label: t("home.hero.stats.filesProcessed") },
{ value: "99.9%", label: t("home.hero.stats.uptime") },
].map((item) => (
<div key={item.label}>
<div className="text-xl font-semibold tracking-tight sm:text-2xl">{item.value}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.label}</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
</div>
</section>
);
}
function ToolsShowcase({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
const items = [
{
icon: Video,
title: t("home.tools.videoToFrames.title"),
description: t("home.tools.videoToFrames.description"),
href: "/tools/video-frames",
gradient: "from-sky-500/20 via-white/[0.03] to-transparent",
},
{
icon: ImageIcon,
title: t("home.tools.imageCompression.title"),
description: t("home.tools.imageCompression.description"),
href: "/tools/image-compress",
gradient: "from-violet-500/20 via-white/[0.03] to-transparent",
},
{
icon: Music,
title: t("home.tools.audioCompression.title"),
description: t("home.tools.audioCompression.description"),
href: "/tools/audio-compress",
gradient: "from-emerald-500/20 via-white/[0.03] to-transparent",
},
];
return (
<section id="tools" className="border-t border-white/5 py-20 sm:py-24">
<div className="container">
<SectionHeader
kicker={t("home.showcase.kicker")}
title={t("home.showcase.title")}
description={t("home.showcase.description")}
/>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{items.map((item, index) => (
<motion.div
key={item.href}
initial={reduceMotion ? undefined : { opacity: 0, y: 18 }}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-120px" }}
transition={{ duration: 0.6, delay: index * 0.08, ease: [0.22, 1, 0.36, 1] }}
>
<Link href={item.href} className="group block h-full">
<Card className="relative h-full overflow-hidden rounded-3xl border-white/10 bg-white/[0.02] p-6 transition-colors hover:bg-white/[0.03]">
<div className={cn("absolute inset-0 bg-gradient-to-b", item.gradient)} />
<div className="absolute inset-0 noise-overlay opacity-[0.06] mix-blend-overlay" />
<div className="relative">
<div className="flex items-start justify-between gap-3">
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-white/[0.06] shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
<item.icon className="h-5 w-5" />
</span>
<span className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-muted-foreground">
{t("common.tryNow")}
</span>
</div>
<h3 className="mt-6 text-lg font-semibold tracking-tight">{item.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.description}</p>
<div className="mt-6 inline-flex items-center text-sm font-medium">
{t("home.showcase.cta")}
<ArrowRight className="ml-2 h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5" />
</div>
</div>
</Card>
</Link>
</motion.div>
))}
</div>
</div>
</section>
);
}
function Workflow({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
const steps = [
{
k: "01",
title: t("home.workflow.steps.step1.title"),
description: t("home.workflow.steps.step1.description"),
icon: Sparkles,
},
{
k: "02",
title: t("home.workflow.steps.step2.title"),
description: t("home.workflow.steps.step2.description"),
icon: Zap,
},
{
k: "03",
title: t("home.workflow.steps.step3.title"),
description: t("home.workflow.steps.step3.description"),
icon: ShieldCheck,
},
];
return (
<section className="py-20 sm:py-24">
<div className="container">
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:items-start">
<div>
<SectionHeader title={t("home.workflow.title")} description={t("home.workflow.description")} />
</div>
<div className="space-y-4">
{steps.map((s, idx) => (
<motion.div
key={s.k}
initial={reduceMotion ? undefined : { opacity: 0, x: 14 }}
whileInView={reduceMotion ? undefined : { opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-120px" }}
transition={{ duration: 0.55, delay: idx * 0.06, ease: [0.22, 1, 0.36, 1] }}
>
<div className="group relative overflow-hidden rounded-3xl border border-white/10 bg-white/[0.02] p-6">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_30%,rgba(255,255,255,0.06),transparent_55%)] opacity-70" />
<div className="absolute inset-0 noise-overlay opacity-[0.06] mix-blend-overlay" />
<div className="relative flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 flex-col items-center justify-center rounded-2xl bg-white/[0.06] text-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
<div className="text-xs font-semibold tracking-[0.22em] text-muted-foreground">{s.k}</div>
<s.icon className="mt-1 h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-base font-semibold tracking-tight">{s.title}</div>
<div className="mt-2 text-sm leading-relaxed text-muted-foreground">{s.description}</div>
</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
}
function Quality({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
const items = [
{
icon: Zap,
title: t("home.quality.items.fast.title"),
description: t("home.quality.items.fast.description"),
},
{
icon: ShieldCheck,
title: t("home.quality.items.private.title"),
description: t("home.quality.items.private.description"),
},
{
icon: Sparkles,
title: getT("home.tools.aiTools.title"),
description: getT("home.tools.aiTools.description"),
href: "/tools/ai-tools",
title: t("home.quality.items.designed.title"),
description: t("home.quality.items.designed.description"),
},
];
}
function useBenefits() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return [
{
icon: Zap,
title: getT("home.benefits.lightningFast.title"),
description: getT("home.benefits.lightningFast.description"),
},
{
icon: Shield,
title: getT("home.benefits.secure.title"),
description: getT("home.benefits.secure.description"),
},
{
icon: Users,
title: getT("home.benefits.forDevelopers.title"),
description: getT("home.benefits.forDevelopers.description"),
},
];
}
function usePricingPlans() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return [
{
name: getT("home.pricing.plans.free.name"),
price: getT("home.pricing.plans.free.price"),
description: getT("home.pricing.plans.free.description"),
features: (mounted ? t("home.pricing.plans.free.features") : getServerTranslations("en").t("home.pricing.plans.free.features")) as unknown as string[],
cta: getT("home.pricing.plans.free.cta"),
href: "/register",
},
{
name: getT("home.pricing.plans.pro.name"),
price: getT("home.pricing.plans.pro.price"),
period: getT("home.pricing.plans.pro.period"),
description: getT("home.pricing.plans.pro.description"),
features: (mounted ? t("home.pricing.plans.pro.features") : getServerTranslations("en").t("home.pricing.plans.pro.features")) as unknown as string[],
cta: getT("home.pricing.plans.pro.cta"),
href: "/pricing",
popular: getT("home.pricing.plans.pro.popular"),
},
{
name: getT("home.pricing.plans.enterprise.name"),
price: getT("home.pricing.plans.enterprise.price"),
description: getT("home.pricing.plans.enterprise.description"),
features: (mounted ? t("home.pricing.plans.enterprise.features") : getServerTranslations("en").t("home.pricing.plans.enterprise.features")) as unknown as string[],
cta: getT("home.pricing.plans.enterprise.cta"),
href: "/contact",
},
];
}
function HeroSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string, params?: any) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
return (
<section className="relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-background to-background" />
<div className="absolute inset-0 bg-[url(/grid.svg)] bg-cover opacity-10" />
</div>
<section className="border-t border-white/5 py-20 sm:py-24">
<div className="container">
<SectionHeader title={t("home.quality.title")} description={t("home.quality.description")} />
<div className="container py-24 md:py-32 xl:py-40 2xl:py-48">
<div className="mt-12 grid gap-6 md:grid-cols-3">
{items.map((item, idx) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mx-auto max-w-5xl text-center 2xl:max-w-6xl 3xl:max-w-7xl"
key={item.title}
initial={reduceMotion ? undefined : { opacity: 0, y: 14 }}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-120px" }}
transition={{ duration: 0.55, delay: idx * 0.06, ease: [0.22, 1, 0.36, 1] }}
>
<Badge className="mb-4" variant="secondary">
<Sparkles className="mr-1 h-3 w-3" />
{getT("home.hero.badge")}
</Badge>
<h1 className="mb-6 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl 2xl:text-9xl">
{getT("home.hero.title")}
</h1>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{getT("home.hero.description")}
<Card className="h-full rounded-3xl border-white/10 bg-white/[0.02] p-6">
<div className="flex items-start gap-4">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-white/[0.06] shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
<item.icon className="h-5 w-5" />
</div>
<div>
<div className="text-base font-semibold tracking-tight">{item.title}</div>
<div className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.description}</div>
</div>
</div>
</Card>
</motion.div>
))}
</div>
</div>
</section>
);
}
function FinalCTA({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
return (
<section className="py-20 sm:py-24">
<div className="container">
<motion.div
initial={reduceMotion ? undefined : { opacity: 0, y: 16 }}
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-120px" }}
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-white/[0.03] p-10 sm:p-12"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,hsl(var(--primary)/0.22),transparent_55%),radial-gradient(circle_at_80%_70%,rgba(255,255,255,0.08),transparent_55%)]" />
<div className="absolute inset-0 noise-overlay opacity-[0.09] mix-blend-overlay" />
<div className="relative mx-auto max-w-2xl text-center">
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
{t("home.final.title")}
</h2>
<p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">
{t("home.final.description")}
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/tools">
{getT("home.hero.startBuilding")} <ArrowRight className="ml-2 h-4 w-4" />
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Button asChild size="lg" className="rounded-full px-7">
<Link href="/tools/image-compress">
{t("home.final.primaryCta")} <ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/pricing">{getT("common.viewPricing")}</Link>
</Button>
</div>
{/* Stats */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mt-16 grid grid-cols-3 gap-8 md:gap-16 xl:gap-24 2xl:gap-32"
>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">10K+</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{getT("home.hero.stats.developers")}</div>
</div>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">1M+</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{getT("home.hero.stats.filesProcessed")}</div>
</div>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">99.9%</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{getT("home.hero.stats.uptime")}</div>
</div>
</motion.div>
</motion.div>
</div>
</section>
);
}
function FeaturesSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const features = useFeatures();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-16 text-center xl:mb-20 2xl:mb-24"
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
{getT("home.featuresSection.title")}
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{getT("home.featuresSection.description")}
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 xl:gap-8 2xl:gap-10">
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={feature.href}>
<Card className="h-full transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 xl:p-8 2xl:p-10">
<CardHeader>
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 xl:h-14 xl:w-14 2xl:h-16 2xl:w-16">
<feature.icon className="h-6 w-6 text-primary xl:h-7 xl:w-7 2xl:h-8 2xl:w-8" />
</div>
<CardTitle className="text-xl xl:text-2xl 2xl:text-3xl">{feature.title}</CardTitle>
<CardDescription className="text-base xl:text-lg 2xl:text-xl">{feature.description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="ghost" size="sm" className="w-full xl:text-base 2xl:text-lg">
{getT("common.tryNow")} <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
</Link>
</motion.div>
))}
</div>
</div>
</section>
);
}
function BenefitsSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const benefits = useBenefits();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return (
<section className="py-24 xl:py-32 2xl:py-40">
<div className="container">
<div className="grid gap-12 lg:grid-cols-2 xl:gap-16 2xl:gap-20">
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
{getT("home.benefits.title")}
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{getT("home.benefits.description")}
</p>
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/about">{getT("common.learnMore")}</Link>
</Button>
</motion.div>
<div className="space-y-6 xl:space-y-8">
{benefits.map((benefit, index) => (
<motion.div
key={benefit.title}
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card className="xl:p-8 2xl:p-10">
<CardContent className="flex gap-4 p-6 xl:gap-6 2xl:gap-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10 xl:h-14 xl:w-14 2xl:h-16 2xl:w-16">
<benefit.icon className="h-6 w-6 text-primary xl:h-7 xl:w-7 2xl:h-8 2xl:w-8" />
</div>
<div>
<h3 className="mb-2 text-lg font-semibold xl:text-xl 2xl:text-2xl">{benefit.title}</h3>
<p className="text-muted-foreground text-sm md:text-base xl:text-lg 2xl:text-xl">{benefit.description}</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
}
function PricingSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const pricingPlans = usePricingPlans();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-16 text-center xl:mb-20 2xl:mb-24"
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
{getT("home.pricing.title")}
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{getT("home.pricing.description")}
</p>
</motion.div>
<div className="grid gap-8 md:grid-cols-3 xl:gap-10 2xl:gap-12">
{pricingPlans.map((plan, index) => (
<motion.div
key={plan.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card className={`relative h-full ${plan.popular ? "border-primary" : ""} xl:p-8 2xl:p-10`}>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 xl:text-base 2xl:text-lg">
{plan.popular}
</Badge>
)}
<CardHeader>
<CardTitle className="text-xl xl:text-2xl 2xl:text-3xl">{plan.name}</CardTitle>
<CardDescription className="text-base xl:text-lg 2xl:text-xl">{plan.description}</CardDescription>
<div className="mt-4">
<span className="text-4xl font-bold xl:text-5xl 2xl:text-6xl">{plan.price}</span>
{plan.period && (
<span className="text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{plan.period}</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-5 w-5 shrink-0 text-primary xl:h-6 xl:w-6 2xl:h-7 2xl:w-7" />
<span className="text-sm md:text-base xl:text-lg 2xl:text-xl">{feature}</span>
</li>
))}
</ul>
<Button className="w-full xl:text-lg xl:py-6 2xl:text-xl 2xl:py-7" variant={plan.popular ? "default" : "outline"} asChild>
<Link href={plan.href}>{plan.cta}</Link>
</Button>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
</section>
);
}
function CTASection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return (
<section className="py-24 xl:py-32 2xl:py-40">
<div className="container">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-primary/20 via-primary/10 to-background p-12 text-center md:p-20 xl:p-24 2xl:p-32"
>
<div className="relative z-10">
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
{getT("home.cta.title")}
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{getT("home.cta.description")}
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/register">{getT("home.cta.getStarted")}</Link>
</Button>
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/contact">{getT("common.contactSales")}</Link>
<Button asChild size="lg" variant="outline" className="rounded-full border-white/10 bg-white/[0.02] px-7 hover:bg-white/[0.04]">
<Link href="/tools/video-frames">{t("home.final.secondaryCta")}</Link>
</Button>
</div>
</div>
@@ -440,13 +449,16 @@ function CTASection() {
}
export default function HomePage() {
const t = useStableT();
const reduceMotion = useReducedMotion() ?? false;
return (
<>
<HeroSection />
<FeaturesSection />
<BenefitsSection />
<PricingSection />
<CTASection />
</>
<div className="relative">
<Hero t={t} reduceMotion={reduceMotion} />
<ToolsShowcase t={t} reduceMotion={reduceMotion} />
<Workflow t={t} reduceMotion={reduceMotion} />
<Quality t={t} reduceMotion={reduceMotion} />
<FinalCTA t={t} reduceMotion={reduceMotion} />
</div>
);
}

View File

@@ -1,175 +1,96 @@
"use client";
import Link from "next/link";
import { Sparkles, Github, Twitter } from "lucide-react";
import { Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { useState, useEffect } from "react";
function useFooterLinks() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return {
product: [
{ name: getT("common.features"), href: "/features" },
{ name: getT("nav.pricing"), href: "/pricing" },
{ name: "API", href: "/api" },
{ name: getT("nav.docs"), href: "/docs" },
],
tools: [
{ 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: getT("home.tools.aiTools.title"), href: "/tools/ai-tools" },
],
company: [
{ name: getT("nav.about"), href: "/about" },
{ name: "Blog", href: "/blog" },
{ name: "Careers", href: "/careers" },
{ name: "Contact", href: "/contact" },
],
legal: [
{ name: "Privacy", href: "/privacy" },
{ name: "Terms", href: "/terms" },
{ name: "Cookie Policy", href: "/cookies" },
],
};
}
const socialLinks = [
{ name: "Twitter", icon: Twitter, href: "https://twitter.com" },
{ name: "GitHub", icon: Github, href: "https://github.com" },
];
const sectionTitles: Record<string, string> = {
product: "Product",
tools: "Tools",
company: "Company",
legal: "Legal",
};
export function Footer() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const footerLinks = useFooterLinks();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
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(
() => [
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress" },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[mounted]
);
return (
<footer className="border-t border-border/40 bg-background/50">
<div className="container py-12 md:py-16">
<div className="grid grid-cols-2 gap-8 md:grid-cols-6">
{/* Brand */}
<div className="col-span-2">
<Link href="/" className="flex items-center space-x-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">{getT("common.appName")}</span>
<footer className="border-t border-white/5 bg-background/50">
<div className="container py-12">
<div className="grid gap-10 md:grid-cols-[1.5fr_1fr]">
<div>
<Link href="/" className="inline-flex items-center gap-2">
<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>
</Link>
<p className="mt-4 text-sm text-muted-foreground">
<p className="mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
{getT("footer.tagline")}
</p>
</div>
{/* Product */}
<div>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.product}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.product.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Tools */}
<div>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.tools}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.tools.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Company */}
<div>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.company}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.company.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Legal */}
<div>
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.legal}</h3>
<ul className="space-y-3 text-sm">
{footerLinks.legal.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Bottom section */}
<div className="mt-12 flex flex-col items-center justify-between border-t border-border/40 pt-8 md:flex-row">
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} {getT("common.appName")}. All rights reserved.
<p className="mt-6 text-xs text-muted-foreground">
{getT("footer.note")}
</p>
<div className="mt-4 flex space-x-6 md:mt-0">
{socialLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={link.name}
>
<Icon className="h-5 w-5" />
</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")}
</div>
<ul className="mt-4 space-y-2 text-sm">
{toolLinks.map((link) => (
<li key={link.href}>
<Link href={link.href} className="text-muted-foreground hover:text-foreground">
{link.name}
</Link>
</li>
))}
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{getT("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")}
</Link>
</li>
</ul>
</div>
</div>
</div>
<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")}
</p>
<Link
href="https://beian.miit.gov.cn"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground"
>
ICP备2021179543号
</Link>
);
})}
</div>
</div>
</div>

View File

@@ -2,30 +2,18 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
import { useEffect, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Sparkles } from "lucide-react";
// import { Button } from "@/components/ui/button"; // TODO: Uncomment when adding login/register buttons
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 { cn } from "@/lib/utils";
function useNavItems() {
const { t } = useTranslation();
return [
{ name: t("nav.tools"), href: "/tools/image-compress" },
{ name: t("nav.pricing"), href: "/tools/video-frames" },
{ name: t("nav.docs"), href: "/tools/audio-compress" },
// Note: Temporarily redirecting to existing tool pages
// TODO: Create dedicated pages for pricing, docs, about
];
}
export function Header() {
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navItems = useNavItems();
const { t, locale, setLocale } = useTranslation();
useEffect(() => {
@@ -35,118 +23,115 @@ export function Header() {
const stored = localStorage.getItem("locale-storage");
if (!stored) {
const lang = navigator.language.toLowerCase();
if (lang.includes("zh")) {
setLocale("zh");
}
if (lang.includes("zh")) setLocale("zh");
}
}, [setLocale]);
useEffect(() => {
if (mounted) {
if (!mounted) return;
document.documentElement.lang = locale;
}
}, [locale, mounted]);
// Prevent hydration mismatch by rendering a stable version initially
const displayT = (key: string, params?: any) => {
const displayT = (key: string, params?: Record<string, string | number>) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
const currentNavItems = mounted ? navItems : [
{ name: "Tools", href: "/tools/image-compress" },
{ name: "Pricing", href: "/tools/video-frames" },
{ name: "Docs", href: "/tools/audio-compress" },
];
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" },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[mounted, locale]
);
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<header className="sticky top-0 z-50 w-full border-b border-white/5 bg-background/70 backdrop-blur-xl supports-[backdrop-filter]:bg-background/50">
<nav className="container flex h-16 items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">{displayT("common.appName")}</span>
<Link href="/" className="group flex items-center gap-2">
<span className="relative 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 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>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex md:items-center md:space-x-6">
{currentNavItems.map((item) => (
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => {
const active = pathname === item.href || pathname?.startsWith(item.href + "/");
return (
<Link
key={item.name}
key={item.href}
href={item.href}
className={cn(
"text-sm font-medium transition-colors hover:text-primary",
pathname === item.href ? "text-primary" : "text-muted-foreground"
"rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-white/8 text-foreground"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
)}
>
{item.name}
</Link>
))}
);
})}
</div>
{/* CTA Buttons */}
<div className="hidden md:flex md:items-center md:space-x-4">
<div className="hidden md:flex items-center gap-2">
{mounted && <LanguageSwitcher />}
{/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild>
<Link href="/login">{displayT("common.signIn")}</Link>
<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" />
</Link>
</Button>
<Button size="sm" asChild>
<Link href="/register">{displayT("common.getStarted")}</Link>
</Button>
*/}
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden"
className="md:hidden rounded-full p-2 hover:bg-white/5"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label={displayT("common.toggleMenu")}
>
{isMobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
{isMobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</nav>
{/* Mobile Menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="md:hidden border-t border-border/40"
transition={{ duration: 0.18 }}
className="md:hidden border-t border-white/5"
>
<div className="container space-y-4 py-6">
{currentNavItems.map((item) => (
<div className="container py-5">
<div className="flex flex-col gap-2">
{navItems.map((item) => (
<Link
key={item.name}
key={item.href}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
"block text-sm font-medium transition-colors hover:text-primary",
pathname === item.href ? "text-primary" : "text-muted-foreground"
"rounded-xl px-3 py-2 text-sm font-medium transition-colors",
pathname === item.href
? "bg-white/8 text-foreground"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
)}
>
{item.name}
</Link>
))}
<div className="flex flex-col space-y-2 pt-4">
</div>
<div className="mt-4 flex items-center justify-between">
{mounted && <LanguageSwitcher />}
{/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild className="w-full">
<Link href="/login">{displayT("common.signIn")}</Link>
<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" />
</Link>
</Button>
<Button size="sm" asChild className="w-full">
<Link href="/register">{displayT("common.getStarted")}</Link>
</Button>
*/}
</div>
</div>
</motion.div>

View File

@@ -7,7 +7,6 @@ import {
Video,
Image,
Music,
Sparkles,
LayoutDashboard,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -39,21 +38,6 @@ function useSidebarNavItems() {
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
],
},
{
title: getT("sidebar.aiTools"),
items: [
{ name: getT("sidebar.aiImage"), href: "/tools/ai-tools", icon: Sparkles },
{ name: getT("sidebar.aiAudio"), href: "/tools/ai-tools", icon: Sparkles },
],
},
{
title: getT("nav.account"),
items: [
// TODO: Create pricing and settings pages
// { name: t("nav.pricing"), href: "/pricing", icon: CreditCard },
// { name: t("common.settings"), href: "/settings", icon: Settings },
],
},
];
}
@@ -62,8 +46,6 @@ interface SidebarProps {
}
export function Sidebar({ className }: SidebarProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const pathname = usePathname();
const sidebarNavItems = useSidebarNavItems();

View File

@@ -18,8 +18,6 @@
"learnMore": "Learn More",
"getStarted": "Get Started",
"startBuilding": "Start Building",
"viewPricing": "View Pricing",
"contactSales": "Contact Sales",
"signIn": "Sign In",
"register": "Register",
"features": "Features",
@@ -35,18 +33,20 @@
},
"nav": {
"tools": "Tools",
"pricing": "Pricing",
"docs": "Docs",
"about": "About",
"dashboard": "Dashboard",
"overview": "Overview",
"account": "Account"
"overview": "Overview"
},
"home": {
"hero": {
"badge": "Media Processing Tools",
"kicker": "Built for asset prep",
"title": "Empowering Game Development",
"description": "Video to frames, image compression, audio optimization. Everything you need to prepare game assets, in one place.",
"startBuilding": "Start Building",
"secondaryCta": "Explore tools",
"note": "No plugins. No setup. Just ship assets faster.",
"previewTitle": "Asset Processing Workbench",
"stats": {
"developers": "Developers",
"filesProcessed": "Files Processed",
@@ -57,6 +57,54 @@
"title": "Everything You Need",
"description": "Powerful tools designed specifically for game developers"
},
"showcase": {
"kicker": "Three tools. Zero clutter.",
"title": "A smoother pipeline for everyday assets",
"description": "Minimal controls. Predictable results. Designed to keep you in flow.",
"cta": "Open tool"
},
"workflow": {
"title": "Treat assets like a build step",
"description": "Clear inputs, clear outputs. Repeatable settings. Less trial-and-error.",
"steps": {
"step1": {
"title": "Drop files",
"description": "Batch-friendly. Drag files in and keep moving."
},
"step2": {
"title": "Tune the essentials",
"description": "Only the knobs you actually need: quality, FPS, formats."
},
"step3": {
"title": "Export clean outputs",
"description": "Structured results that fit right into art and engineering workflows."
}
}
},
"quality": {
"title": "Fast, stable, intentional",
"description": "Not feature bloat—just a better interaction loop.",
"items": {
"fast": {
"title": "Lightning fast",
"description": "Spend less time waiting and more time building."
},
"private": {
"title": "Secure by default",
"description": "Minimize exposure and keep processing straightforward."
},
"designed": {
"title": "Designed for devs",
"description": "Clear information hierarchy and repeatable rhythm across tools."
}
}
},
"final": {
"title": "Start now. Keep your time for the game.",
"description": "Pick a tool and build a faster workflow from the first asset.",
"primaryCta": "Get started",
"secondaryCta": "Try Video to Frames"
},
"tools": {
"videoToFrames": {
"title": "Video to Frames",
@@ -91,39 +139,10 @@
"description": "API access, batch processing, and tools designed for game development workflows."
}
},
"pricing": {
"title": "Simple, Transparent Pricing",
"description": "Start free, scale as you grow. No hidden fees.",
"plans": {
"free": {
"name": "Free",
"price": "$0",
"description": "Perfect for trying out",
"features": ["10 processes per day", "50MB max file size", "Basic tools", "Community support"],
"cta": "Get Started"
},
"pro": {
"name": "Pro",
"price": "$19",
"period": "/month",
"description": "For serious developers",
"features": ["Unlimited processes", "500MB max file size", "All tools including AI", "Priority support", "API access"],
"cta": "Start Free Trial",
"popular": "Most Popular"
},
"enterprise": {
"name": "Enterprise",
"price": "Custom",
"description": "For teams and businesses",
"features": ["Everything in Pro", "Unlimited file size", "Custom integrations", "Dedicated support", "SLA guarantee"],
"cta": "Contact Sales"
}
}
},
"cta": {
"title": "Ready to Level Up?",
"description": "Join thousands of game developers building amazing games with our tools.",
"getStarted": "Get Started for Free"
"getStarted": "Start Creating"
}
},
"sidebar": {
@@ -250,6 +269,15 @@
"saved": "Saved {{ratio}}%"
},
"footer": {
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio."
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.",
"note": "Inspired by modern product storytelling—centered on your workflow, not UI noise.",
"sections": {
"tools": "Tools",
"company": "Product"
},
"links": {
"home": "Home"
},
"copyright": "All rights reserved"
}
}

View File

@@ -18,8 +18,6 @@
"learnMore": "了解更多",
"getStarted": "开始使用",
"startBuilding": "开始创作",
"viewPricing": "查看价格",
"contactSales": "联系销售",
"signIn": "登录",
"register": "注册",
"features": "功能",
@@ -35,18 +33,20 @@
},
"nav": {
"tools": "工具",
"pricing": "价格",
"docs": "文档",
"about": "关于",
"dashboard": "仪表盘",
"overview": "概览",
"account": "账户"
"overview": "概览"
},
"home": {
"hero": {
"badge": "媒体处理工具",
"kicker": "为素材准备提速",
"title": "为小游戏开发提供全链路提效赋能",
"description": "视频抽帧、图片压缩、音频优化。一站式游戏素材处理工具,让开发更高效。",
"startBuilding": "开始创作",
"secondaryCta": "了解工具",
"note": "无需安装插件。打开即用。",
"previewTitle": "素材处理工作台",
"stats": {
"developers": "开发者",
"filesProcessed": "文件处理量",
@@ -57,6 +57,54 @@
"title": "您需要的一切",
"description": "专为游戏开发者设计的强大工具"
},
"showcase": {
"kicker": "三件事,刚好够用",
"title": "把素材准备变成一条顺畅的流水线",
"description": "每个工具只保留最关键的控制项:更少打断,更快出结果。",
"cta": "打开工具"
},
"workflow": {
"title": "像写代码一样处理素材",
"description": "清晰、可预期、可复用。把“试试”变成“稳定产出”。",
"steps": {
"step1": {
"title": "拖进来",
"description": "支持批量文件。把要处理的素材直接丢进页面。"
},
"step2": {
"title": "调到刚好",
"description": "只给你真正需要的参数:质量、帧率、格式。"
},
"step3": {
"title": "拿走结果",
"description": "输出结构清晰,便于集成到美术/工程流程。"
}
}
},
"quality": {
"title": "快、稳、讲究",
"description": "不是花哨的功能堆叠,而是每一次点击都更顺手。",
"items": {
"fast": {
"title": "极速处理",
"description": "高频操作更快完成,把等待时间还给创作。"
},
"private": {
"title": "安全私密",
"description": "文件处理遵循最小化原则,减少不必要的暴露。"
},
"designed": {
"title": "为开发者打造",
"description": "简洁的信息架构与可复制的操作节奏,降低学习成本。"
}
}
},
"final": {
"title": "现在就开始,把时间留给游戏本身",
"description": "选一个工具,从第一份素材开始建立你的高效流程。",
"primaryCta": "立即开始",
"secondaryCta": "先试试抽帧"
},
"tools": {
"videoToFrames": {
"title": "视频抽帧",
@@ -91,39 +139,10 @@
"description": "API 访问、批量处理,以及专为游戏开发工作流程设计的工具。"
}
},
"pricing": {
"title": "简单透明的定价",
"description": "免费开始,随您增长。无隐藏费用。",
"plans": {
"free": {
"name": "免费版",
"price": "¥0",
"description": "适合尝试使用",
"features": ["每天 10 次处理", "最大 50MB 文件", "基础工具", "社区支持"],
"cta": "开始使用"
},
"pro": {
"name": "专业版",
"price": "¥99",
"period": "/月",
"description": "适合专业开发者",
"features": ["无限处理次数", "最大 500MB 文件", "所有工具包括 AI", "优先支持", "API 访问"],
"cta": "免费试用",
"popular": "最受欢迎"
},
"enterprise": {
"name": "企业版",
"price": "定制",
"description": "适合团队和企业",
"features": ["专业版所有功能", "无限文件大小", "定制集成", "专属支持", "SLA 保证"],
"cta": "联系销售"
}
}
},
"cta": {
"title": "准备好升级了吗?",
"description": "加入数千名使用我们工具开发精彩游戏的开发者。",
"getStarted": "免费开始"
"getStarted": "开始创作"
}
},
"sidebar": {
@@ -250,6 +269,15 @@
"saved": "节省 {{ratio}}%"
},
"footer": {
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。"
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",
"sections": {
"tools": "工具",
"company": "产品"
},
"links": {
"home": "首页"
},
"copyright": "保留所有权利"
}
}