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