fix: 修复服务端渲染时的翻译不匹配问题

This commit is contained in:
2026-01-24 16:38:47 +08:00
parent e2280b12e2
commit b7402edf6a
11 changed files with 319 additions and 184 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { Music, Volume2 } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
@@ -10,7 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, AudioCompressConfig } from "@/types";
const audioAccept = {
@@ -24,15 +24,13 @@ const defaultConfig: AudioCompressConfig = {
channels: 2,
};
function useConfigOptions(config: AudioCompressConfig): ConfigOption[] {
const { t } = useTranslation();
function useConfigOptions(config: AudioCompressConfig, getT: (key: string) => string): ConfigOption[] {
return [
{
id: "bitrate",
type: "select",
label: t("config.audioCompression.bitrate"),
description: t("config.audioCompression.bitrateDescription"),
label: getT("config.audioCompression.bitrate"),
description: getT("config.audioCompression.bitrateDescription"),
value: config.bitrate,
options: [
{ label: "64 kbps", value: 64 },
@@ -45,8 +43,8 @@ function useConfigOptions(config: AudioCompressConfig): ConfigOption[] {
{
id: "format",
type: "select",
label: t("config.audioCompression.format"),
description: t("config.audioCompression.formatDescription"),
label: getT("config.audioCompression.format"),
description: getT("config.audioCompression.formatDescription"),
value: config.format,
options: [
{ label: "MP3", value: "mp3" },
@@ -58,8 +56,8 @@ function useConfigOptions(config: AudioCompressConfig): ConfigOption[] {
{
id: "sampleRate",
type: "select",
label: t("config.audioCompression.sampleRate"),
description: t("config.audioCompression.sampleRateDescription"),
label: getT("config.audioCompression.sampleRate"),
description: getT("config.audioCompression.sampleRateDescription"),
value: config.sampleRate,
options: [
{ label: "44.1 kHz", value: 44100 },
@@ -69,19 +67,27 @@ function useConfigOptions(config: AudioCompressConfig): ConfigOption[] {
{
id: "channels",
type: "radio",
label: t("config.audioCompression.channels"),
description: t("config.audioCompression.channelsDescription"),
label: getT("config.audioCompression.channels"),
description: getT("config.audioCompression.channelsDescription"),
value: config.channels,
options: [
{ label: t("config.audioCompression.stereo"), value: 2 },
{ label: t("config.audioCompression.mono"), value: 1 },
{ label: getT("config.audioCompression.stereo"), value: 2 },
{ label: getT("config.audioCompression.mono"), value: 1 },
],
},
];
}
export default function AudioCompressPage() {
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);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
@@ -118,7 +124,7 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "uploading",
progress: 0,
message: t("processing.uploadingAudio"),
message: getT("processing.uploadingAudio"),
});
try {
@@ -128,14 +134,14 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "uploading",
progress: i,
message: t("processing.uploadProgress", { progress: i }),
message: getT("processing.uploadProgress", { progress: i }),
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: t("processing.compressingAudio"),
message: getT("processing.compressingAudio"),
});
// Simulate processing
@@ -144,7 +150,7 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "processing",
progress: i,
message: t("processing.compressProgress", { progress: i }),
message: getT("processing.compressProgress", { progress: i }),
});
}
@@ -168,14 +174,14 @@ export default function AudioCompressPage() {
setProcessingStatus({
status: "completed",
progress: 100,
message: t("processing.compressionComplete"),
message: getT("processing.compressionComplete"),
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: t("processing.compressionFailed"),
error: error instanceof Error ? error.message : t("processing.unknownError"),
message: getT("processing.compressionFailed"),
error: error instanceof Error ? error.message : getT("processing.unknownError"),
});
}
};
@@ -185,7 +191,7 @@ export default function AudioCompressPage() {
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
const configOptions = useConfigOptions(config);
const configOptions = useConfigOptions(config, getT);
return (
<div className="p-6">
@@ -199,9 +205,9 @@ export default function AudioCompressPage() {
<Music className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">{t("tools.audioCompression.title")}</h1>
<h1 className="text-3xl font-bold">{getT("tools.audioCompression.title")}</h1>
<p className="text-muted-foreground">
{t("tools.audioCompression.description")}
{getT("tools.audioCompression.description")}
</p>
</div>
</div>
@@ -220,8 +226,8 @@ export default function AudioCompressPage() {
/>
<ConfigPanel
title={t("config.audioCompression.title")}
description={t("config.audioCompression.description")}
title={getT("config.audioCompression.title")}
description={getT("config.audioCompression.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof AudioCompressConfig],
@@ -233,7 +239,7 @@ export default function AudioCompressPage() {
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Volume2 className="mr-2 h-4 w-4" />
{t("tools.audioCompression.compressAudio")}
{getT("tools.audioCompression.compressAudio")}
</Button>
)}
</div>
@@ -248,15 +254,15 @@ export default function AudioCompressPage() {
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">{t("tools.audioCompression.supportedFormats")}</h3>
<h3 className="mb-3 font-semibold">{getT("tools.audioCompression.supportedFormats")}</h3>
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground">
<div>
<p className="font-medium text-foreground">{t("tools.audioCompression.input")}</p>
<p>{t("tools.audioCompression.inputFormats")}</p>
<p className="font-medium text-foreground">{getT("tools.audioCompression.input")}</p>
<p>{getT("tools.audioCompression.inputFormats")}</p>
</div>
<div>
<p className="font-medium text-foreground">{t("tools.audioCompression.output")}</p>
<p>{t("tools.audioCompression.outputFormats")}</p>
<p className="font-medium text-foreground">{getT("tools.audioCompression.output")}</p>
<p>{getT("tools.audioCompression.outputFormats")}</p>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { Image as ImageIcon, Zap } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
@@ -10,7 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
const imageAccept = {
@@ -22,15 +22,13 @@ const defaultConfig: ImageCompressConfig = {
format: "original",
};
function useConfigOptions(config: ImageCompressConfig): ConfigOption[] {
const { t } = useTranslation();
function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => string): ConfigOption[] {
return [
{
id: "quality",
type: "slider",
label: t("config.imageCompression.quality"),
description: t("config.imageCompression.qualityDescription"),
label: getT("config.imageCompression.quality"),
description: getT("config.imageCompression.qualityDescription"),
value: config.quality,
min: 1,
max: 100,
@@ -41,21 +39,29 @@ function useConfigOptions(config: ImageCompressConfig): ConfigOption[] {
{
id: "format",
type: "select",
label: t("config.imageCompression.format"),
description: t("config.imageCompression.formatDescription"),
label: getT("config.imageCompression.format"),
description: getT("config.imageCompression.formatDescription"),
value: config.format,
options: [
{ label: t("config.imageCompression.formatOriginal"), value: "original" },
{ label: t("config.imageCompression.formatJpeg"), value: "jpeg" },
{ label: t("config.imageCompression.formatPng"), value: "png" },
{ label: t("config.imageCompression.formatWebp"), value: "webp" },
{ label: getT("config.imageCompression.formatOriginal"), value: "original" },
{ label: getT("config.imageCompression.formatJpeg"), value: "jpeg" },
{ label: getT("config.imageCompression.formatPng"), value: "png" },
{ label: getT("config.imageCompression.formatWebp"), value: "webp" },
],
},
];
}
export default function ImageCompressPage() {
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);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
@@ -92,7 +98,7 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "uploading",
progress: 0,
message: t("processing.uploadingImages"),
message: getT("processing.uploadingImages"),
});
try {
@@ -102,14 +108,14 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "uploading",
progress: i,
message: t("processing.uploadProgress", { progress: i }),
message: getT("processing.uploadProgress", { progress: i }),
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: t("processing.compressingImages"),
message: getT("processing.compressingImages"),
});
// Simulate processing
@@ -118,7 +124,7 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "processing",
progress: i,
message: t("processing.compressProgress", { progress: i }),
message: getT("processing.compressProgress", { progress: i }),
});
}
@@ -141,14 +147,14 @@ export default function ImageCompressPage() {
setProcessingStatus({
status: "completed",
progress: 100,
message: t("processing.compressionComplete"),
message: getT("processing.compressionComplete"),
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: t("processing.compressionFailed"),
error: error instanceof Error ? error.message : t("processing.unknownError"),
message: getT("processing.compressionFailed"),
error: error instanceof Error ? error.message : getT("processing.unknownError"),
});
}
};
@@ -158,7 +164,7 @@ export default function ImageCompressPage() {
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
const configOptions = useConfigOptions(config);
const configOptions = useConfigOptions(config, getT);
return (
<div className="p-6">
@@ -172,9 +178,9 @@ export default function ImageCompressPage() {
<ImageIcon className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">{t("tools.imageCompression.title")}</h1>
<h1 className="text-3xl font-bold">{getT("tools.imageCompression.title")}</h1>
<p className="text-muted-foreground">
{t("tools.imageCompression.description")}
{getT("tools.imageCompression.description")}
</p>
</div>
</div>
@@ -193,8 +199,8 @@ export default function ImageCompressPage() {
/>
<ConfigPanel
title={t("config.imageCompression.title")}
description={t("config.imageCompression.description")}
title={getT("config.imageCompression.title")}
description={getT("config.imageCompression.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof ImageCompressConfig],
@@ -206,7 +212,7 @@ export default function ImageCompressPage() {
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Zap className="mr-2 h-4 w-4" />
{t("tools.imageCompression.compressImages")}
{getT("tools.imageCompression.compressImages")}
</Button>
)}
</div>
@@ -221,9 +227,9 @@ export default function ImageCompressPage() {
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">{t("tools.imageCompression.features")}</h3>
<h3 className="mb-3 font-semibold">{getT("tools.imageCompression.features")}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{(t("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => (
{(getT("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => (
<li key={index}> {feature}</li>
))}
</ul>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { Video, Settings } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
@@ -10,7 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, VideoFramesConfig } from "@/types";
const videoAccept = {
@@ -25,15 +25,13 @@ const defaultConfig: VideoFramesConfig = {
height: undefined,
};
function useConfigOptions(config: VideoFramesConfig): ConfigOption[] {
const { t } = useTranslation();
function useConfigOptions(config: VideoFramesConfig, getT: (key: string) => string): ConfigOption[] {
return [
{
id: "fps",
type: "slider",
label: t("config.videoFrames.fps"),
description: t("config.videoFrames.fpsDescription"),
label: getT("config.videoFrames.fps"),
description: getT("config.videoFrames.fpsDescription"),
value: config.fps,
min: 1,
max: 60,
@@ -44,8 +42,8 @@ function useConfigOptions(config: VideoFramesConfig): ConfigOption[] {
{
id: "format",
type: "select",
label: t("config.videoFrames.format"),
description: t("config.videoFrames.formatDescription"),
label: getT("config.videoFrames.format"),
description: getT("config.videoFrames.formatDescription"),
value: config.format,
options: [
{ label: "PNG", value: "png" },
@@ -56,8 +54,8 @@ function useConfigOptions(config: VideoFramesConfig): ConfigOption[] {
{
id: "quality",
type: "slider",
label: t("config.videoFrames.quality"),
description: t("config.videoFrames.qualityDescription"),
label: getT("config.videoFrames.quality"),
description: getT("config.videoFrames.qualityDescription"),
value: config.quality,
min: 1,
max: 100,
@@ -68,7 +66,15 @@ function useConfigOptions(config: VideoFramesConfig): ConfigOption[] {
}
export default function VideoFramesPage() {
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);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
@@ -105,7 +111,7 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "uploading",
progress: 0,
message: t("processing.uploadingVideo"),
message: getT("processing.uploadingVideo"),
});
try {
@@ -115,14 +121,14 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "uploading",
progress: i,
message: t("processing.uploadProgress", { progress: i }),
message: getT("processing.uploadProgress", { progress: i }),
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: t("processing.extractingFrames"),
message: getT("processing.extractingFrames"),
});
// Simulate processing
@@ -131,7 +137,7 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "processing",
progress: i,
message: t("processing.processProgress", { progress: i }),
message: getT("processing.processProgress", { progress: i }),
});
}
@@ -155,14 +161,14 @@ export default function VideoFramesPage() {
setProcessingStatus({
status: "completed",
progress: 100,
message: t("processing.processingComplete"),
message: getT("processing.processingComplete"),
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: t("processing.processingFailed"),
error: error instanceof Error ? error.message : t("processing.unknownError"),
message: getT("processing.processingFailed"),
error: error instanceof Error ? error.message : getT("processing.unknownError"),
});
}
};
@@ -172,7 +178,7 @@ export default function VideoFramesPage() {
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
const configOptions = useConfigOptions(config);
const configOptions = useConfigOptions(config, getT);
return (
<div className="p-6">
@@ -187,9 +193,9 @@ export default function VideoFramesPage() {
<Video className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">{t("tools.videoFrames.title")}</h1>
<h1 className="text-3xl font-bold">{getT("tools.videoFrames.title")}</h1>
<p className="text-muted-foreground">
{t("tools.videoFrames.description")}
{getT("tools.videoFrames.description")}
</p>
</div>
</div>
@@ -209,8 +215,8 @@ export default function VideoFramesPage() {
/>
<ConfigPanel
title={t("config.videoFrames.title")}
description={t("config.videoFrames.description")}
title={getT("config.videoFrames.title")}
description={getT("config.videoFrames.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof VideoFramesConfig],
@@ -222,7 +228,7 @@ export default function VideoFramesPage() {
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Settings className="mr-2 h-4 w-4" />
{t("tools.videoFrames.processVideo")}
{getT("tools.videoFrames.processVideo")}
</Button>
)}
</div>
@@ -239,9 +245,9 @@ export default function VideoFramesPage() {
{/* Info Card */}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">{t("tools.videoFrames.howItWorks")}</h3>
<h3 className="mb-3 font-semibold">{getT("tools.videoFrames.howItWorks")}</h3>
<ol className="space-y-2 text-sm text-muted-foreground">
{(t("tools.videoFrames.steps") as unknown as string[]).map((step, index) => (
{(getT("tools.videoFrames.steps") as unknown as string[]).map((step, index) => (
<li key={index}>{index + 1}. {step}</li>
))}
</ol>

View File

@@ -16,94 +16,126 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTranslation } from "@/lib/i18n";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { useState, useEffect } from "react";
function useFeatures() {
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: Video,
title: t("home.tools.videoToFrames.title"),
description: t("home.tools.videoToFrames.description"),
title: getT("home.tools.videoToFrames.title"),
description: getT("home.tools.videoToFrames.description"),
href: "/tools/video-frames",
},
{
icon: Image,
title: t("home.tools.imageCompression.title"),
description: t("home.tools.imageCompression.description"),
title: getT("home.tools.imageCompression.title"),
description: getT("home.tools.imageCompression.description"),
href: "/tools/image-compress",
},
{
icon: Music,
title: t("home.tools.audioCompression.title"),
description: t("home.tools.audioCompression.description"),
title: getT("home.tools.audioCompression.title"),
description: getT("home.tools.audioCompression.description"),
href: "/tools/audio-compress",
},
{
icon: Sparkles,
title: t("home.tools.aiTools.title"),
description: t("home.tools.aiTools.description"),
title: getT("home.tools.aiTools.title"),
description: getT("home.tools.aiTools.description"),
href: "/tools/ai-tools",
},
];
}
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: t("home.benefits.lightningFast.title"),
description: t("home.benefits.lightningFast.description"),
title: getT("home.benefits.lightningFast.title"),
description: getT("home.benefits.lightningFast.description"),
},
{
icon: Shield,
title: t("home.benefits.secure.title"),
description: t("home.benefits.secure.description"),
title: getT("home.benefits.secure.title"),
description: getT("home.benefits.secure.description"),
},
{
icon: Users,
title: t("home.benefits.forDevelopers.title"),
description: t("home.benefits.forDevelopers.description"),
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: t("home.pricing.plans.free.name"),
price: t("home.pricing.plans.free.price"),
description: t("home.pricing.plans.free.description"),
features: t("home.pricing.plans.free.features") as unknown as string[],
cta: t("home.pricing.plans.free.cta"),
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: t("home.pricing.plans.pro.name"),
price: t("home.pricing.plans.pro.price"),
period: t("home.pricing.plans.pro.period"),
description: t("home.pricing.plans.pro.description"),
features: t("home.pricing.plans.pro.features") as unknown as string[],
cta: t("home.pricing.plans.pro.cta"),
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: t("home.pricing.plans.pro.popular") as string,
popular: getT("home.pricing.plans.pro.popular"),
},
{
name: t("home.pricing.plans.enterprise.name"),
price: t("home.pricing.plans.enterprise.price"),
description: t("home.pricing.plans.enterprise.description"),
features: t("home.pricing.plans.enterprise.features") as unknown as string[],
cta: t("home.pricing.plans.enterprise.cta"),
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 */}
@@ -121,22 +153,22 @@ function HeroSection() {
>
<Badge className="mb-4" variant="secondary">
<Sparkles className="mr-1 h-3 w-3" />
{t("home.hero.badge")}
{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">
{t("home.hero.title", { speed: t("home.hero.speed") })}
{getT("home.hero.title")}
</h1>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{t("home.hero.description")}
{getT("home.hero.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">
{t("home.hero.startBuilding")} <ArrowRight className="ml-2 h-4 w-4" />
{getT("home.hero.startBuilding")} <ArrowRight className="ml-2 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">{t("common.viewPricing")}</Link>
<Link href="/pricing">{getT("common.viewPricing")}</Link>
</Button>
</div>
@@ -149,15 +181,15 @@ function HeroSection() {
>
<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">{t("home.hero.stats.developers")}</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">{t("home.hero.stats.filesProcessed")}</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">{t("home.hero.stats.uptime")}</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>
@@ -167,9 +199,16 @@ function HeroSection() {
}
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">
@@ -181,10 +220,10 @@ function FeaturesSection() {
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">
{t("home.featuresSection.title")}
{getT("home.featuresSection.title")}
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{t("home.featuresSection.description")}
{getT("home.featuresSection.description")}
</p>
</motion.div>
@@ -208,7 +247,7 @@ function FeaturesSection() {
</CardHeader>
<CardContent>
<Button variant="ghost" size="sm" className="w-full xl:text-base 2xl:text-lg">
{t("common.tryNow")} <ArrowRight className="ml-2 h-4 w-4" />
{getT("common.tryNow")} <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
@@ -222,9 +261,16 @@ function FeaturesSection() {
}
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">
@@ -236,13 +282,13 @@ function BenefitsSection() {
transition={{ duration: 0.5 }}
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
{t("home.benefits.title")}
{getT("home.benefits.title")}
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{t("home.benefits.description")}
{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">{t("common.learnMore")}</Link>
<Link href="/about">{getT("common.learnMore")}</Link>
</Button>
</motion.div>
@@ -276,9 +322,16 @@ function BenefitsSection() {
}
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">
@@ -290,10 +343,10 @@ function PricingSection() {
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">
{t("home.pricing.title")}
{getT("home.pricing.title")}
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{t("home.pricing.description")}
{getT("home.pricing.description")}
</p>
</motion.div>
@@ -345,8 +398,15 @@ function PricingSection() {
}
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">
@@ -359,17 +419,17 @@ function CTASection() {
>
<div className="relative z-10">
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
{t("home.cta.title")}
{getT("home.cta.title")}
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
{t("home.cta.description")}
{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">{t("home.cta.getStarted")}</Link>
<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">{t("common.contactSales")}</Link>
<Link href="/contact">{getT("common.contactSales")}</Link>
</Button>
</div>
</div>

View File

@@ -2,26 +2,34 @@
import Link from "next/link";
import { Sparkles, Github, Twitter } from "lucide-react";
import { useTranslation } from "@/lib/i18n";
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: t("common.features"), href: "/features" },
{ name: t("nav.pricing"), href: "/pricing" },
{ name: getT("common.features"), href: "/features" },
{ name: getT("nav.pricing"), href: "/pricing" },
{ name: "API", href: "/api" },
{ name: t("nav.docs"), href: "/docs" },
{ name: getT("nav.docs"), href: "/docs" },
],
tools: [
{ 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("home.tools.aiTools.title"), href: "/tools/ai-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: t("nav.about"), href: "/about" },
{ name: getT("nav.about"), href: "/about" },
{ name: "Blog", href: "/blog" },
{ name: "Careers", href: "/careers" },
{ name: "Contact", href: "/contact" },
@@ -47,9 +55,16 @@ const sectionTitles: Record<string, string> = {
};
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);
};
return (
<footer className="border-t border-border/40 bg-background/50">
<div className="container py-12 md:py-16">
@@ -60,10 +75,10 @@ export function Footer() {
<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">{t("common.appName")}</span>
<span className="text-xl font-bold">{getT("common.appName")}</span>
</Link>
<p className="mt-4 text-sm text-muted-foreground">
{t("footer.tagline")}
{getT("footer.tagline")}
</p>
</div>
@@ -139,7 +154,7 @@ export function Footer() {
{/* 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()} {t("common.appName")}. All rights reserved.
© {new Date().getFullYear()} {getT("common.appName")}. All rights reserved.
</p>
<div className="mt-4 flex space-x-6 md:mt-0">
{socialLinks.map((link) => {

View File

@@ -2,12 +2,12 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { useState, useEffect } 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 { LanguageSwitcher } from "./LanguageSwitcher";
import { useTranslation } from "@/lib/i18n";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { cn } from "@/lib/utils";
function useNavItems() {
@@ -24,8 +24,40 @@ function useNavItems() {
export function Header() {
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navItems = useNavItems();
const { t } = useTranslation();
const { t, locale, setLocale } = useTranslation();
useEffect(() => {
setMounted(true);
// Auto detect language on first mount if not manually set
const stored = localStorage.getItem("locale-storage");
if (!stored) {
const lang = navigator.language.toLowerCase();
if (lang.includes("zh")) {
setLocale("zh");
}
}
}, [setLocale]);
useEffect(() => {
if (mounted) {
document.documentElement.lang = locale;
}
}, [locale, mounted]);
// Prevent hydration mismatch by rendering a stable version initially
const displayT = (key: string, params?: any) => {
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" },
];
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">
@@ -35,12 +67,12 @@ export function Header() {
<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">{t("common.appName")}</span>
<span className="text-xl font-bold">{displayT("common.appName")}</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex md:items-center md:space-x-6">
{navItems.map((item) => (
{currentNavItems.map((item) => (
<Link
key={item.name}
href={item.href}
@@ -56,13 +88,13 @@ export function Header() {
{/* CTA Buttons */}
<div className="hidden md:flex md:items-center md:space-x-4">
<LanguageSwitcher />
{mounted && <LanguageSwitcher />}
{/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild>
<Link href="/login">{t("common.signIn")}</Link>
<Link href="/login">{displayT("common.signIn")}</Link>
</Button>
<Button size="sm" asChild>
<Link href="/register">{t("common.getStarted")}</Link>
<Link href="/register">{displayT("common.getStarted")}</Link>
</Button>
*/}
</div>
@@ -71,7 +103,7 @@ export function Header() {
<button
className="md:hidden"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
aria-label={displayT("common.toggleMenu")}
>
{isMobileMenuOpen ? (
<X className="h-6 w-6" />
@@ -92,7 +124,7 @@ export function Header() {
className="md:hidden border-t border-border/40"
>
<div className="container space-y-4 py-6">
{navItems.map((item) => (
{currentNavItems.map((item) => (
<Link
key={item.name}
href={item.href}
@@ -106,13 +138,13 @@ export function Header() {
</Link>
))}
<div className="flex flex-col space-y-2 pt-4">
<LanguageSwitcher />
{mounted && <LanguageSwitcher />}
{/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild className="w-full">
<Link href="/login">{t("common.signIn")}</Link>
<Link href="/login">{displayT("common.signIn")}</Link>
</Button>
<Button size="sm" asChild className="w-full">
<Link href="/register">{t("common.getStarted")}</Link>
<Link href="/register">{displayT("common.getStarted")}</Link>
</Button>
*/}
</div>

View File

@@ -25,19 +25,17 @@ export function LanguageSwitcher() {
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden md:inline">{locales[locale].flag}</span>
<span className="hidden lg:inline">{locales[locale].name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[160px]">
{Object.entries(locales).map(([key, { name, flag }]) => (
<DropdownMenuContent align="end" className="min-w-[120px]">
{Object.entries(locales).map(([key, { name }]) => (
<DropdownMenuItem
key={key}
onClick={() => handleLocaleChange(key as Locale)}
className="flex cursor-pointer items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
<span>{flag}</span>
<span>{name}</span>
</span>
{locale === key && (

View File

@@ -11,34 +11,43 @@ import {
LayoutDashboard,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { useState, useEffect } from "react";
function useSidebarNavItems() {
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 [
{
title: t("nav.dashboard"),
title: getT("nav.dashboard"),
items: [
{ name: t("nav.overview"), href: "/", icon: LayoutDashboard },
{ name: getT("nav.overview"), href: "/", icon: LayoutDashboard },
],
},
{
title: t("sidebar.tools"),
title: getT("sidebar.tools"),
items: [
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video },
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image },
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video },
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image },
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
],
},
{
title: t("sidebar.aiTools"),
title: getT("sidebar.aiTools"),
items: [
{ name: t("sidebar.aiImage"), href: "/tools/ai-tools", icon: Sparkles },
{ name: t("sidebar.aiAudio"), href: "/tools/ai-tools", icon: Sparkles },
{ name: getT("sidebar.aiImage"), href: "/tools/ai-tools", icon: Sparkles },
{ name: getT("sidebar.aiAudio"), href: "/tools/ai-tools", icon: Sparkles },
],
},
{
title: t("nav.account"),
title: getT("nav.account"),
items: [
// TODO: Create pricing and settings pages
// { name: t("nav.pricing"), href: "/pricing", icon: CreditCard },
@@ -53,6 +62,8 @@ interface SidebarProps {
}
export function Sidebar({ className }: SidebarProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const pathname = usePathname();
const sidebarNavItems = useSidebarNavItems();

View File

@@ -6,9 +6,9 @@ import zh from "@/locales/zh.json";
export type Locale = "en" | "zh";
export type LocaleMessages = typeof en;
export const locales: Record<Locale, { name: string; flag: string }> = {
en: { name: "English", flag: "🇺🇸" },
zh: { name: "中文", flag: "🇨🇳" },
export const locales: Record<Locale, { name: string }> = {
en: { name: "English" },
zh: { name: "中文" },
};
const messages: Record<Locale, LocaleMessages> = { en, zh };
@@ -34,13 +34,12 @@ function interpolate(template: string, params: Record<string, string | number |
export const useI18nStore = create<I18nState>()(
persist(
(set, get) => ({
locale: "en",
locale: "en", // Default to en for SSR/Hydration
setLocale: (locale) => set({ locale }),
t: (key, params) => {
const { locale } = get();
const value = getNestedValue(messages[locale], key);
// If the value is not a string (e.g., an array), return it as-is
if (typeof value !== "string") return value as string;
if (!params || Object.keys(params).length === 0) return value;
return interpolate(value, params);

View File

@@ -24,6 +24,7 @@
"register": "Register",
"features": "Features",
"settings": "Settings",
"toggleMenu": "Toggle menu",
"processing": "Processing...",
"uploading": "Uploading...",
"completed": "Completed!",

View File

@@ -24,6 +24,7 @@
"register": "注册",
"features": "功能",
"settings": "设置",
"toggleMenu": "切换菜单",
"processing": "处理中...",
"uploading": "上传中...",
"completed": "已完成!",