fix: 修复服务端渲染时的翻译不匹配问题
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
164
src/app/page.tsx
164
src/app/page.tsx
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"register": "Register",
|
||||
"features": "Features",
|
||||
"settings": "Settings",
|
||||
"toggleMenu": "Toggle menu",
|
||||
"processing": "Processing...",
|
||||
"uploading": "Uploading...",
|
||||
"completed": "Completed!",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"register": "注册",
|
||||
"features": "功能",
|
||||
"settings": "设置",
|
||||
"toggleMenu": "切换菜单",
|
||||
"processing": "处理中...",
|
||||
"uploading": "上传中...",
|
||||
"completed": "已完成!",
|
||||
|
||||
Reference in New Issue
Block a user