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

View File

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

View File

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

View File

@@ -16,94 +16,126 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useTranslation } from "@/lib/i18n"; import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { useState, useEffect } from "react";
function useFeatures() { function useFeatures() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return [ return [
{ {
icon: Video, icon: Video,
title: t("home.tools.videoToFrames.title"), title: getT("home.tools.videoToFrames.title"),
description: t("home.tools.videoToFrames.description"), description: getT("home.tools.videoToFrames.description"),
href: "/tools/video-frames", href: "/tools/video-frames",
}, },
{ {
icon: Image, icon: Image,
title: t("home.tools.imageCompression.title"), title: getT("home.tools.imageCompression.title"),
description: t("home.tools.imageCompression.description"), description: getT("home.tools.imageCompression.description"),
href: "/tools/image-compress", href: "/tools/image-compress",
}, },
{ {
icon: Music, icon: Music,
title: t("home.tools.audioCompression.title"), title: getT("home.tools.audioCompression.title"),
description: t("home.tools.audioCompression.description"), description: getT("home.tools.audioCompression.description"),
href: "/tools/audio-compress", href: "/tools/audio-compress",
}, },
{ {
icon: Sparkles, icon: Sparkles,
title: t("home.tools.aiTools.title"), title: getT("home.tools.aiTools.title"),
description: t("home.tools.aiTools.description"), description: getT("home.tools.aiTools.description"),
href: "/tools/ai-tools", href: "/tools/ai-tools",
}, },
]; ];
} }
function useBenefits() { function useBenefits() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return [ return [
{ {
icon: Zap, icon: Zap,
title: t("home.benefits.lightningFast.title"), title: getT("home.benefits.lightningFast.title"),
description: t("home.benefits.lightningFast.description"), description: getT("home.benefits.lightningFast.description"),
}, },
{ {
icon: Shield, icon: Shield,
title: t("home.benefits.secure.title"), title: getT("home.benefits.secure.title"),
description: t("home.benefits.secure.description"), description: getT("home.benefits.secure.description"),
}, },
{ {
icon: Users, icon: Users,
title: t("home.benefits.forDevelopers.title"), title: getT("home.benefits.forDevelopers.title"),
description: t("home.benefits.forDevelopers.description"), description: getT("home.benefits.forDevelopers.description"),
}, },
]; ];
} }
function usePricingPlans() { function usePricingPlans() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return [ return [
{ {
name: t("home.pricing.plans.free.name"), name: getT("home.pricing.plans.free.name"),
price: t("home.pricing.plans.free.price"), price: getT("home.pricing.plans.free.price"),
description: t("home.pricing.plans.free.description"), description: getT("home.pricing.plans.free.description"),
features: t("home.pricing.plans.free.features") as unknown as string[], features: (mounted ? t("home.pricing.plans.free.features") : getServerTranslations("en").t("home.pricing.plans.free.features")) as unknown as string[],
cta: t("home.pricing.plans.free.cta"), cta: getT("home.pricing.plans.free.cta"),
href: "/register", href: "/register",
}, },
{ {
name: t("home.pricing.plans.pro.name"), name: getT("home.pricing.plans.pro.name"),
price: t("home.pricing.plans.pro.price"), price: getT("home.pricing.plans.pro.price"),
period: t("home.pricing.plans.pro.period"), period: getT("home.pricing.plans.pro.period"),
description: t("home.pricing.plans.pro.description"), description: getT("home.pricing.plans.pro.description"),
features: t("home.pricing.plans.pro.features") as unknown as string[], features: (mounted ? t("home.pricing.plans.pro.features") : getServerTranslations("en").t("home.pricing.plans.pro.features")) as unknown as string[],
cta: t("home.pricing.plans.pro.cta"), cta: getT("home.pricing.plans.pro.cta"),
href: "/pricing", 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"), name: getT("home.pricing.plans.enterprise.name"),
price: t("home.pricing.plans.enterprise.price"), price: getT("home.pricing.plans.enterprise.price"),
description: t("home.pricing.plans.enterprise.description"), description: getT("home.pricing.plans.enterprise.description"),
features: t("home.pricing.plans.enterprise.features") as unknown as string[], features: (mounted ? t("home.pricing.plans.enterprise.features") : getServerTranslations("en").t("home.pricing.plans.enterprise.features")) as unknown as string[],
cta: t("home.pricing.plans.enterprise.cta"), cta: getT("home.pricing.plans.enterprise.cta"),
href: "/contact", href: "/contact",
}, },
]; ];
} }
function HeroSection() { function HeroSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const getT = (key: string, params?: any) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
return ( return (
<section className="relative overflow-hidden"> <section className="relative overflow-hidden">
{/* Background gradient */} {/* Background gradient */}
@@ -121,22 +153,22 @@ function HeroSection() {
> >
<Badge className="mb-4" variant="secondary"> <Badge className="mb-4" variant="secondary">
<Sparkles className="mr-1 h-3 w-3" /> <Sparkles className="mr-1 h-3 w-3" />
{t("home.hero.badge")} {getT("home.hero.badge")}
</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"> <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> </h1>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl"> <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> </p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6"> <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"> <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"> <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> </Link>
</Button> </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"> <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> </Button>
</div> </div>
@@ -149,15 +181,15 @@ function HeroSection() {
> >
<div> <div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">10K+</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> <div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">1M+</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> <div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">99.9%</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> </div>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -167,9 +199,16 @@ function HeroSection() {
} }
function FeaturesSection() { function FeaturesSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const features = useFeatures(); const features = useFeatures();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return ( return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40"> <section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container"> <div className="container">
@@ -181,10 +220,10 @@ function FeaturesSection() {
className="mb-16 text-center xl:mb-20 2xl:mb-24" 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"> <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> </h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl"> <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> </p>
</motion.div> </motion.div>
@@ -208,7 +247,7 @@ function FeaturesSection() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button variant="ghost" size="sm" className="w-full xl:text-base 2xl:text-lg"> <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> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -222,9 +261,16 @@ function FeaturesSection() {
} }
function BenefitsSection() { function BenefitsSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const benefits = useBenefits(); const benefits = useBenefits();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return ( return (
<section className="py-24 xl:py-32 2xl:py-40"> <section className="py-24 xl:py-32 2xl:py-40">
<div className="container"> <div className="container">
@@ -236,13 +282,13 @@ function BenefitsSection() {
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl"> <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> </h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl"> <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> </p>
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7"> <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> </Button>
</motion.div> </motion.div>
@@ -276,9 +322,16 @@ function BenefitsSection() {
} }
function PricingSection() { function PricingSection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const pricingPlans = usePricingPlans(); const pricingPlans = usePricingPlans();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return ( return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40"> <section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container"> <div className="container">
@@ -290,10 +343,10 @@ function PricingSection() {
className="mb-16 text-center xl:mb-20 2xl:mb-24" 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"> <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> </h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl"> <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> </p>
</motion.div> </motion.div>
@@ -345,8 +398,15 @@ function PricingSection() {
} }
function CTASection() { function CTASection() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return ( return (
<section className="py-24 xl:py-32 2xl:py-40"> <section className="py-24 xl:py-32 2xl:py-40">
<div className="container"> <div className="container">
@@ -359,17 +419,17 @@ function CTASection() {
> >
<div className="relative z-10"> <div className="relative z-10">
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl"> <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> </h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl"> <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> </p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6"> <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"> <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>
<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"> <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> </Button>
</div> </div>
</div> </div>

View File

@@ -2,26 +2,34 @@
import Link from "next/link"; import Link from "next/link";
import { Sparkles, Github, Twitter } from "lucide-react"; 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() { function useFooterLinks() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return { return {
product: [ product: [
{ name: t("common.features"), href: "/features" }, { name: getT("common.features"), href: "/features" },
{ name: t("nav.pricing"), href: "/pricing" }, { name: getT("nav.pricing"), href: "/pricing" },
{ name: "API", href: "/api" }, { name: "API", href: "/api" },
{ name: t("nav.docs"), href: "/docs" }, { name: getT("nav.docs"), href: "/docs" },
], ],
tools: [ tools: [
{ name: t("sidebar.videoToFrames"), href: "/tools/video-frames" }, { name: getT("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: t("sidebar.imageCompression"), href: "/tools/image-compress" }, { name: getT("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: t("sidebar.audioCompression"), href: "/tools/audio-compress" }, { name: getT("sidebar.audioCompression"), href: "/tools/audio-compress" },
{ name: t("home.tools.aiTools.title"), href: "/tools/ai-tools" }, { name: getT("home.tools.aiTools.title"), href: "/tools/ai-tools" },
], ],
company: [ company: [
{ name: t("nav.about"), href: "/about" }, { name: getT("nav.about"), href: "/about" },
{ name: "Blog", href: "/blog" }, { name: "Blog", href: "/blog" },
{ name: "Careers", href: "/careers" }, { name: "Careers", href: "/careers" },
{ name: "Contact", href: "/contact" }, { name: "Contact", href: "/contact" },
@@ -47,9 +55,16 @@ const sectionTitles: Record<string, string> = {
}; };
export function Footer() { export function Footer() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation(); const { t } = useTranslation();
const footerLinks = useFooterLinks(); const footerLinks = useFooterLinks();
const getT = (key: string) => {
if (!mounted) return getServerTranslations("en").t(key);
return t(key);
};
return ( return (
<footer className="border-t border-border/40 bg-background/50"> <footer className="border-t border-border/40 bg-background/50">
<div className="container py-12 md:py-16"> <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"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" /> <Sparkles className="h-5 w-5 text-primary-foreground" />
</div> </div>
<span className="text-xl font-bold">{t("common.appName")}</span> <span className="text-xl font-bold">{getT("common.appName")}</span>
</Link> </Link>
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
{t("footer.tagline")} {getT("footer.tagline")}
</p> </p>
</div> </div>
@@ -139,7 +154,7 @@ export function Footer() {
{/* Bottom section */} {/* Bottom section */}
<div className="mt-12 flex flex-col items-center justify-between border-t border-border/40 pt-8 md:flex-row"> <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"> <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> </p>
<div className="mt-4 flex space-x-6 md:mt-0"> <div className="mt-4 flex space-x-6 md:mt-0">
{socialLinks.map((link) => { {socialLinks.map((link) => {

View File

@@ -2,12 +2,12 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react"; import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Sparkles } from "lucide-react"; import { Menu, X, Sparkles } from "lucide-react";
// import { Button } from "@/components/ui/button"; // TODO: Uncomment when adding login/register buttons // import { Button } from "@/components/ui/button"; // TODO: Uncomment when adding login/register buttons
import { LanguageSwitcher } from "./LanguageSwitcher"; import { LanguageSwitcher } from "./LanguageSwitcher";
import { useTranslation } from "@/lib/i18n"; import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function useNavItems() { function useNavItems() {
@@ -24,8 +24,40 @@ function useNavItems() {
export function Header() { export function Header() {
const pathname = usePathname(); const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const navItems = useNavItems(); 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 ( 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-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"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" /> <Sparkles className="h-5 w-5 text-primary-foreground" />
</div> </div>
<span className="text-xl font-bold">{t("common.appName")}</span> <span className="text-xl font-bold">{displayT("common.appName")}</span>
</Link> </Link>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<div className="hidden md:flex md:items-center md:space-x-6"> <div className="hidden md:flex md:items-center md:space-x-6">
{navItems.map((item) => ( {currentNavItems.map((item) => (
<Link <Link
key={item.name} key={item.name}
href={item.href} href={item.href}
@@ -56,13 +88,13 @@ export function Header() {
{/* CTA Buttons */} {/* CTA Buttons */}
<div className="hidden md:flex md:items-center md:space-x-4"> <div className="hidden md:flex md:items-center md:space-x-4">
<LanguageSwitcher /> {mounted && <LanguageSwitcher />}
{/* TODO: Create login/register pages {/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>
<Link href="/login">{t("common.signIn")}</Link> <Link href="/login">{displayT("common.signIn")}</Link>
</Button> </Button>
<Button size="sm" asChild> <Button size="sm" asChild>
<Link href="/register">{t("common.getStarted")}</Link> <Link href="/register">{displayT("common.getStarted")}</Link>
</Button> </Button>
*/} */}
</div> </div>
@@ -71,7 +103,7 @@ export function Header() {
<button <button
className="md:hidden" className="md:hidden"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu" aria-label={displayT("common.toggleMenu")}
> >
{isMobileMenuOpen ? ( {isMobileMenuOpen ? (
<X className="h-6 w-6" /> <X className="h-6 w-6" />
@@ -92,7 +124,7 @@ export function Header() {
className="md:hidden border-t border-border/40" className="md:hidden border-t border-border/40"
> >
<div className="container space-y-4 py-6"> <div className="container space-y-4 py-6">
{navItems.map((item) => ( {currentNavItems.map((item) => (
<Link <Link
key={item.name} key={item.name}
href={item.href} href={item.href}
@@ -106,13 +138,13 @@ export function Header() {
</Link> </Link>
))} ))}
<div className="flex flex-col space-y-2 pt-4"> <div className="flex flex-col space-y-2 pt-4">
<LanguageSwitcher /> {mounted && <LanguageSwitcher />}
{/* TODO: Create login/register pages {/* TODO: Create login/register pages
<Button variant="ghost" size="sm" asChild className="w-full"> <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>
<Button size="sm" asChild className="w-full"> <Button size="sm" asChild className="w-full">
<Link href="/register">{t("common.getStarted")}</Link> <Link href="/register">{displayT("common.getStarted")}</Link>
</Button> </Button>
*/} */}
</div> </div>

View File

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

View File

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

View File

@@ -6,9 +6,9 @@ import zh from "@/locales/zh.json";
export type Locale = "en" | "zh"; export type Locale = "en" | "zh";
export type LocaleMessages = typeof en; export type LocaleMessages = typeof en;
export const locales: Record<Locale, { name: string; flag: string }> = { export const locales: Record<Locale, { name: string }> = {
en: { name: "English", flag: "🇺🇸" }, en: { name: "English" },
zh: { name: "中文", flag: "🇨🇳" }, zh: { name: "中文" },
}; };
const messages: Record<Locale, LocaleMessages> = { en, zh }; 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>()( export const useI18nStore = create<I18nState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
locale: "en", locale: "en", // Default to en for SSR/Hydration
setLocale: (locale) => set({ locale }), setLocale: (locale) => set({ locale }),
t: (key, params) => { t: (key, params) => {
const { locale } = get(); const { locale } = get();
const value = getNestedValue(messages[locale], key); 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 (typeof value !== "string") return value as string;
if (!params || Object.keys(params).length === 0) return value; if (!params || Object.keys(params).length === 0) return value;
return interpolate(value, params); return interpolate(value, params);

View File

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

View File

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