feat: 支持多语言能力
This commit is contained in:
39
src/app/(dashboard)/tools/audio-compress/layout.tsx
Normal file
39
src/app/(dashboard)/tools/audio-compress/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language") || "";
|
||||
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
|
||||
|
||||
const titles = {
|
||||
en: "Audio Compression - Compress & Convert Audio Files",
|
||||
zh: "音频压缩 - 压缩并转换音频文件",
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
en: "Compress and convert audio files to various formats including MP3, AAC, OGG, FLAC. Adjust bitrate and sample rate for optimal quality.",
|
||||
zh: "压缩并转换音频文件为多种格式,包括 MP3、AAC、OGG、FLAC。调整比特率和采样率以获得最佳质量。",
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
twitter: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function AudioCompressLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -10,6 +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 type { UploadedFile, ProcessedFile, AudioCompressConfig } from "@/types";
|
||||
|
||||
const audioAccept = {
|
||||
@@ -23,59 +24,64 @@ const defaultConfig: AudioCompressConfig = {
|
||||
channels: 2,
|
||||
};
|
||||
|
||||
const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "bitrate",
|
||||
type: "select",
|
||||
label: "Bitrate",
|
||||
description: "Higher bitrate = better quality, larger file",
|
||||
value: defaultConfig.bitrate,
|
||||
options: [
|
||||
{ label: "64 kbps", value: 64 },
|
||||
{ label: "128 kbps", value: 128 },
|
||||
{ label: "192 kbps", value: 192 },
|
||||
{ label: "256 kbps", value: 256 },
|
||||
{ label: "320 kbps", value: 320 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: "Output Format",
|
||||
description: "Target audio format",
|
||||
value: defaultConfig.format,
|
||||
options: [
|
||||
{ label: "MP3", value: "mp3" },
|
||||
{ label: "AAC", value: "aac" },
|
||||
{ label: "OGG", value: "ogg" },
|
||||
{ label: "FLAC", value: "flac" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sampleRate",
|
||||
type: "select",
|
||||
label: "Sample Rate",
|
||||
description: "Audio sample rate in Hz",
|
||||
value: defaultConfig.sampleRate,
|
||||
options: [
|
||||
{ label: "44.1 kHz", value: 44100 },
|
||||
{ label: "48 kHz", value: 48000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
type: "radio",
|
||||
label: "Channels",
|
||||
description: "Audio channels",
|
||||
value: defaultConfig.channels,
|
||||
options: [
|
||||
{ label: "Stereo (2 channels)", value: 2 },
|
||||
{ label: "Mono (1 channel)", value: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
function useConfigOptions(config: AudioCompressConfig): ConfigOption[] {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "bitrate",
|
||||
type: "select",
|
||||
label: t("config.audioCompression.bitrate"),
|
||||
description: t("config.audioCompression.bitrateDescription"),
|
||||
value: config.bitrate,
|
||||
options: [
|
||||
{ label: "64 kbps", value: 64 },
|
||||
{ label: "128 kbps", value: 128 },
|
||||
{ label: "192 kbps", value: 192 },
|
||||
{ label: "256 kbps", value: 256 },
|
||||
{ label: "320 kbps", value: 320 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: t("config.audioCompression.format"),
|
||||
description: t("config.audioCompression.formatDescription"),
|
||||
value: config.format,
|
||||
options: [
|
||||
{ label: "MP3", value: "mp3" },
|
||||
{ label: "AAC", value: "aac" },
|
||||
{ label: "OGG", value: "ogg" },
|
||||
{ label: "FLAC", value: "flac" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sampleRate",
|
||||
type: "select",
|
||||
label: t("config.audioCompression.sampleRate"),
|
||||
description: t("config.audioCompression.sampleRateDescription"),
|
||||
value: config.sampleRate,
|
||||
options: [
|
||||
{ label: "44.1 kHz", value: 44100 },
|
||||
{ label: "48 kHz", value: 48000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
type: "radio",
|
||||
label: t("config.audioCompression.channels"),
|
||||
description: t("config.audioCompression.channelsDescription"),
|
||||
value: config.channels,
|
||||
options: [
|
||||
{ label: t("config.audioCompression.stereo"), value: 2 },
|
||||
{ label: t("config.audioCompression.mono"), value: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function AudioCompressPage() {
|
||||
const { t } = useTranslation();
|
||||
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
|
||||
useUploadStore();
|
||||
|
||||
@@ -112,7 +118,7 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
message: "Uploading audio...",
|
||||
message: t("processing.uploadingAudio"),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -122,14 +128,14 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: i,
|
||||
message: `Uploading... ${i}%`,
|
||||
message: t("processing.uploadProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
message: "Compressing audio...",
|
||||
message: t("processing.compressingAudio"),
|
||||
});
|
||||
|
||||
// Simulate processing
|
||||
@@ -138,7 +144,7 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: i,
|
||||
message: `Compressing... ${i}%`,
|
||||
message: t("processing.compressProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,14 +168,14 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
message: "Compression complete!",
|
||||
message: t("processing.compressionComplete"),
|
||||
});
|
||||
} catch (error) {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: "Compression failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: t("processing.compressionFailed"),
|
||||
error: error instanceof Error ? error.message : t("processing.unknownError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -179,6 +185,7 @@ export default function AudioCompressPage() {
|
||||
};
|
||||
|
||||
const canProcess = files.length > 0 && processingStatus.status !== "processing";
|
||||
const configOptions = useConfigOptions(config);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -192,9 +199,9 @@ export default function AudioCompressPage() {
|
||||
<Music className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Audio Compression</h1>
|
||||
<h1 className="text-3xl font-bold">{t("tools.audioCompression.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Compress and convert audio files with quality control
|
||||
{t("tools.audioCompression.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,8 +220,8 @@ export default function AudioCompressPage() {
|
||||
/>
|
||||
|
||||
<ConfigPanel
|
||||
title="Audio Settings"
|
||||
description="Configure compression parameters"
|
||||
title={t("config.audioCompression.title")}
|
||||
description={t("config.audioCompression.description")}
|
||||
options={configOptions.map((opt) => ({
|
||||
...opt,
|
||||
value: config[opt.id as keyof AudioCompressConfig],
|
||||
@@ -226,7 +233,7 @@ export default function AudioCompressPage() {
|
||||
{canProcess && (
|
||||
<Button onClick={handleProcess} size="lg" className="w-full">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
Compress Audio
|
||||
{t("tools.audioCompression.compressAudio")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -241,15 +248,15 @@ export default function AudioCompressPage() {
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
|
||||
<h3 className="mb-3 font-semibold">Supported Formats</h3>
|
||||
<h3 className="mb-3 font-semibold">{t("tools.audioCompression.supportedFormats")}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Input</p>
|
||||
<p>MP3, WAV, OGG, AAC, FLAC, M4A</p>
|
||||
<p className="font-medium text-foreground">{t("tools.audioCompression.input")}</p>
|
||||
<p>{t("tools.audioCompression.inputFormats")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Output</p>
|
||||
<p>MP3, AAC, OGG, FLAC</p>
|
||||
<p className="font-medium text-foreground">{t("tools.audioCompression.output")}</p>
|
||||
<p>{t("tools.audioCompression.outputFormats")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
39
src/app/(dashboard)/tools/image-compress/layout.tsx
Normal file
39
src/app/(dashboard)/tools/image-compress/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language") || "";
|
||||
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
|
||||
|
||||
const titles = {
|
||||
en: "Image Compression - Optimize Images for Web & Mobile",
|
||||
zh: "图片压缩 - 为网页和移动端优化图片",
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
en: "Optimize images for web and mobile without quality loss. Support for batch processing and format conversion including PNG, JPEG, WebP.",
|
||||
zh: "为网页和移动端优化图片,不影响质量。支持批量处理和格式转换,包括 PNG、JPEG、WebP。",
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
twitter: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function ImageCompressLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -10,6 +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 type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
|
||||
|
||||
const imageAccept = {
|
||||
@@ -21,35 +22,40 @@ const defaultConfig: ImageCompressConfig = {
|
||||
format: "original",
|
||||
};
|
||||
|
||||
const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "quality",
|
||||
type: "slider",
|
||||
label: "Compression Quality",
|
||||
description: "Lower quality = smaller file size",
|
||||
value: defaultConfig.quality,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
suffix: "%",
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: "Output Format",
|
||||
description: "Convert to a different format (optional)",
|
||||
value: defaultConfig.format,
|
||||
options: [
|
||||
{ label: "Original", value: "original" },
|
||||
{ label: "JPEG", value: "jpeg" },
|
||||
{ label: "PNG", value: "png" },
|
||||
{ label: "WebP", value: "webp" },
|
||||
],
|
||||
},
|
||||
];
|
||||
function useConfigOptions(config: ImageCompressConfig): ConfigOption[] {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "quality",
|
||||
type: "slider",
|
||||
label: t("config.imageCompression.quality"),
|
||||
description: t("config.imageCompression.qualityDescription"),
|
||||
value: config.quality,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
suffix: "%",
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: t("config.imageCompression.format"),
|
||||
description: t("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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function ImageCompressPage() {
|
||||
const { t } = useTranslation();
|
||||
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
|
||||
useUploadStore();
|
||||
|
||||
@@ -86,7 +92,7 @@ export default function ImageCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
message: "Uploading images...",
|
||||
message: t("processing.uploadingImages"),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -96,14 +102,14 @@ export default function ImageCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: i,
|
||||
message: `Uploading... ${i}%`,
|
||||
message: t("processing.uploadProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
message: "Compressing images...",
|
||||
message: t("processing.compressingImages"),
|
||||
});
|
||||
|
||||
// Simulate processing
|
||||
@@ -112,7 +118,7 @@ export default function ImageCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: i,
|
||||
message: `Compressing... ${i}%`,
|
||||
message: t("processing.compressProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,14 +141,14 @@ export default function ImageCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
message: "Compression complete!",
|
||||
message: t("processing.compressionComplete"),
|
||||
});
|
||||
} catch (error) {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: "Compression failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: t("processing.compressionFailed"),
|
||||
error: error instanceof Error ? error.message : t("processing.unknownError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -152,6 +158,7 @@ export default function ImageCompressPage() {
|
||||
};
|
||||
|
||||
const canProcess = files.length > 0 && processingStatus.status !== "processing";
|
||||
const configOptions = useConfigOptions(config);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -165,9 +172,9 @@ export default function ImageCompressPage() {
|
||||
<ImageIcon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Image Compression</h1>
|
||||
<h1 className="text-3xl font-bold">{t("tools.imageCompression.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Optimize images for web and mobile without quality loss
|
||||
{t("tools.imageCompression.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,8 +193,8 @@ export default function ImageCompressPage() {
|
||||
/>
|
||||
|
||||
<ConfigPanel
|
||||
title="Compression Settings"
|
||||
description="Configure compression options"
|
||||
title={t("config.imageCompression.title")}
|
||||
description={t("config.imageCompression.description")}
|
||||
options={configOptions.map((opt) => ({
|
||||
...opt,
|
||||
value: config[opt.id as keyof ImageCompressConfig],
|
||||
@@ -199,7 +206,7 @@ export default function ImageCompressPage() {
|
||||
{canProcess && (
|
||||
<Button onClick={handleProcess} size="lg" className="w-full">
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Compress Images
|
||||
{t("tools.imageCompression.compressImages")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -214,12 +221,11 @@ export default function ImageCompressPage() {
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
|
||||
<h3 className="mb-3 font-semibold">Features</h3>
|
||||
<h3 className="mb-3 font-semibold">{t("tools.imageCompression.features")}</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>• Batch processing - compress multiple images at once</li>
|
||||
<li>• Smart compression - maintains visual quality</li>
|
||||
<li>• Format conversion - PNG to JPEG, WebP, and more</li>
|
||||
<li>• Up to 80% size reduction without quality loss</li>
|
||||
{(t("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => (
|
||||
<li key={index}>• {feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
39
src/app/(dashboard)/tools/video-frames/layout.tsx
Normal file
39
src/app/(dashboard)/tools/video-frames/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language") || "";
|
||||
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
|
||||
|
||||
const titles = {
|
||||
en: "Video to Frames - Extract Frames from Videos",
|
||||
zh: "视频抽帧 - 从视频中提取帧",
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
en: "Extract frames from videos with customizable frame rates. Perfect for sprite animations and game asset preparation. Supports MP4, MOV, AVI, WebM.",
|
||||
zh: "从视频中提取帧,可自定义帧率。非常适合精灵动画制作和游戏素材准备。支持 MP4、MOV、AVI、WebM。",
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
twitter: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function VideoFramesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -10,6 +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 type { UploadedFile, ProcessedFile, VideoFramesConfig } from "@/types";
|
||||
|
||||
const videoAccept = {
|
||||
@@ -24,45 +25,50 @@ const defaultConfig: VideoFramesConfig = {
|
||||
height: undefined,
|
||||
};
|
||||
|
||||
const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "fps",
|
||||
type: "slider",
|
||||
label: "Frame Rate",
|
||||
description: "Number of frames to extract per second",
|
||||
value: defaultConfig.fps,
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 1,
|
||||
suffix: " fps",
|
||||
icon: <Video className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: "Output Format",
|
||||
description: "Image format for the extracted frames",
|
||||
value: defaultConfig.format,
|
||||
options: [
|
||||
{ label: "PNG", value: "png" },
|
||||
{ label: "JPEG", value: "jpeg" },
|
||||
{ label: "WebP", value: "webp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
type: "slider",
|
||||
label: "Quality",
|
||||
description: "Image quality (for JPEG and WebP)",
|
||||
value: defaultConfig.quality,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
suffix: "%",
|
||||
},
|
||||
];
|
||||
function useConfigOptions(config: VideoFramesConfig): ConfigOption[] {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "fps",
|
||||
type: "slider",
|
||||
label: t("config.videoFrames.fps"),
|
||||
description: t("config.videoFrames.fpsDescription"),
|
||||
value: config.fps,
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 1,
|
||||
suffix: " fps",
|
||||
icon: <Video className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: t("config.videoFrames.format"),
|
||||
description: t("config.videoFrames.formatDescription"),
|
||||
value: config.format,
|
||||
options: [
|
||||
{ label: "PNG", value: "png" },
|
||||
{ label: "JPEG", value: "jpeg" },
|
||||
{ label: "WebP", value: "webp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
type: "slider",
|
||||
label: t("config.videoFrames.quality"),
|
||||
description: t("config.videoFrames.qualityDescription"),
|
||||
value: config.quality,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
suffix: "%",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function VideoFramesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
|
||||
useUploadStore();
|
||||
|
||||
@@ -99,7 +105,7 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
message: "Uploading video...",
|
||||
message: t("processing.uploadingVideo"),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -109,14 +115,14 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: i,
|
||||
message: `Uploading... ${i}%`,
|
||||
message: t("processing.uploadProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
message: "Extracting frames...",
|
||||
message: t("processing.extractingFrames"),
|
||||
});
|
||||
|
||||
// Simulate processing
|
||||
@@ -125,7 +131,7 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: i,
|
||||
message: `Processing... ${i}%`,
|
||||
message: t("processing.processProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,24 +155,24 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
message: "Processing complete!",
|
||||
message: t("processing.processingComplete"),
|
||||
});
|
||||
} catch (error) {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: "Processing failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: t("processing.processingFailed"),
|
||||
error: error instanceof Error ? error.message : t("processing.unknownError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (fileId: string) => {
|
||||
console.log("Downloading file:", fileId);
|
||||
// Implement download logic
|
||||
};
|
||||
|
||||
const canProcess = files.length > 0 && processingStatus.status !== "processing";
|
||||
const configOptions = useConfigOptions(config);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -181,9 +187,9 @@ export default function VideoFramesPage() {
|
||||
<Video className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Video to Frames</h1>
|
||||
<h1 className="text-3xl font-bold">{t("tools.videoFrames.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Extract frames from videos with customizable settings
|
||||
{t("tools.videoFrames.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,8 +209,8 @@ export default function VideoFramesPage() {
|
||||
/>
|
||||
|
||||
<ConfigPanel
|
||||
title="Export Settings"
|
||||
description="Configure how frames are extracted"
|
||||
title={t("config.videoFrames.title")}
|
||||
description={t("config.videoFrames.description")}
|
||||
options={configOptions.map((opt) => ({
|
||||
...opt,
|
||||
value: config[opt.id as keyof VideoFramesConfig],
|
||||
@@ -216,7 +222,7 @@ export default function VideoFramesPage() {
|
||||
{canProcess && (
|
||||
<Button onClick={handleProcess} size="lg" className="w-full">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Process Video
|
||||
{t("tools.videoFrames.processVideo")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,12 +239,11 @@ 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">How it works</h3>
|
||||
<h3 className="mb-3 font-semibold">{t("tools.videoFrames.howItWorks")}</h3>
|
||||
<ol className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>1. Upload your video file (MP4, MOV, AVI, etc.)</li>
|
||||
<li>2. Configure frame rate, format, and quality</li>
|
||||
<li>3. Click "Process Video" to start extraction</li>
|
||||
<li>4. Download the ZIP file with all frames</li>
|
||||
{(t("tools.videoFrames.steps") as unknown as string[]).map((step, index) => (
|
||||
<li key={index}>{index + 1}. {step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
229
src/app/page.tsx
229
src/app/page.tsx
@@ -16,99 +16,94 @@ 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";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Video,
|
||||
title: "Video to Frames",
|
||||
description: "Extract frames from videos with customizable frame rates and formats. Perfect for sprite animations.",
|
||||
href: "/tools/video-frames",
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: "Image Compression",
|
||||
description: "Optimize images for web and mobile without quality loss. Support for batch processing.",
|
||||
href: "/tools/image-compress",
|
||||
},
|
||||
{
|
||||
icon: Music,
|
||||
title: "Audio Compression",
|
||||
description: "Compress and convert audio files to various formats. Adjust bitrate and sample rate.",
|
||||
href: "/tools/audio-compress",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "AI-Powered Tools",
|
||||
description: "Enhance your assets with AI. Upscale images, remove backgrounds, and more.",
|
||||
href: "/tools/ai-tools",
|
||||
},
|
||||
];
|
||||
function useFeatures() {
|
||||
const { t } = useTranslation();
|
||||
return [
|
||||
{
|
||||
icon: Video,
|
||||
title: t("home.tools.videoToFrames.title"),
|
||||
description: t("home.tools.videoToFrames.description"),
|
||||
href: "/tools/video-frames",
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: t("home.tools.imageCompression.title"),
|
||||
description: t("home.tools.imageCompression.description"),
|
||||
href: "/tools/image-compress",
|
||||
},
|
||||
{
|
||||
icon: Music,
|
||||
title: t("home.tools.audioCompression.title"),
|
||||
description: t("home.tools.audioCompression.description"),
|
||||
href: "/tools/audio-compress",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: t("home.tools.aiTools.title"),
|
||||
description: t("home.tools.aiTools.description"),
|
||||
href: "/tools/ai-tools",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Lightning Fast",
|
||||
description: "Process files in seconds with our optimized infrastructure.",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Secure & Private",
|
||||
description: "Your files are encrypted and automatically deleted after processing.",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Built for Developers",
|
||||
description: "API access, batch processing, and tools designed for game development workflows.",
|
||||
},
|
||||
];
|
||||
function useBenefits() {
|
||||
const { t } = useTranslation();
|
||||
return [
|
||||
{
|
||||
icon: Zap,
|
||||
title: t("home.benefits.lightningFast.title"),
|
||||
description: t("home.benefits.lightningFast.description"),
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: t("home.benefits.secure.title"),
|
||||
description: t("home.benefits.secure.description"),
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: t("home.benefits.forDevelopers.title"),
|
||||
description: t("home.benefits.forDevelopers.description"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
description: "Perfect for trying out",
|
||||
features: [
|
||||
"10 processes per day",
|
||||
"50MB max file size",
|
||||
"Basic tools",
|
||||
"Community support",
|
||||
],
|
||||
cta: "Get Started",
|
||||
href: "/register",
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$19",
|
||||
period: "/month",
|
||||
description: "For serious developers",
|
||||
features: [
|
||||
"Unlimited processes",
|
||||
"500MB max file size",
|
||||
"All tools including AI",
|
||||
"Priority support",
|
||||
"API access",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
href: "/pricing",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
description: "For teams and businesses",
|
||||
features: [
|
||||
"Everything in Pro",
|
||||
"Unlimited file size",
|
||||
"Custom integrations",
|
||||
"Dedicated support",
|
||||
"SLA guarantee",
|
||||
],
|
||||
cta: "Contact Sales",
|
||||
href: "/contact",
|
||||
},
|
||||
];
|
||||
function usePricingPlans() {
|
||||
const { t } = useTranslation();
|
||||
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"),
|
||||
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"),
|
||||
href: "/pricing",
|
||||
popular: t("home.pricing.plans.pro.popular") as string,
|
||||
},
|
||||
{
|
||||
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"),
|
||||
href: "/contact",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function HeroSection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
@@ -126,26 +121,22 @@ function HeroSection() {
|
||||
>
|
||||
<Badge className="mb-4" variant="secondary">
|
||||
<Sparkles className="mr-1 h-3 w-3" />
|
||||
AI-Powered Tools
|
||||
{t("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">
|
||||
Build Games{" "}
|
||||
<span className="bg-gradient-to-r from-purple-400 via-pink-500 to-blue-500 bg-clip-text text-transparent">
|
||||
Faster
|
||||
</span>
|
||||
{t("home.hero.title", { speed: t("home.hero.speed") })}
|
||||
</h1>
|
||||
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Transform your game development workflow with powerful AI tools. Video to frames,
|
||||
image compression, audio processing, and more.
|
||||
{t("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">
|
||||
Start Building <ArrowRight className="ml-2 h-4 w-4" />
|
||||
{t("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">View Pricing</Link>
|
||||
<Link href="/pricing">{t("common.viewPricing")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -158,15 +149,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">Developers</div>
|
||||
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{t("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">Files Processed</div>
|
||||
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{t("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">Uptime</div>
|
||||
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{t("home.hero.stats.uptime")}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
@@ -176,6 +167,9 @@ function HeroSection() {
|
||||
}
|
||||
|
||||
function FeaturesSection() {
|
||||
const { t } = useTranslation();
|
||||
const features = useFeatures();
|
||||
|
||||
return (
|
||||
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
@@ -187,10 +181,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">
|
||||
Everything You Need
|
||||
{t("home.featuresSection.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Powerful tools designed specifically for game developers
|
||||
{t("home.featuresSection.description")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -214,7 +208,7 @@ function FeaturesSection() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="ghost" size="sm" className="w-full xl:text-base 2xl:text-lg">
|
||||
Try it now <ArrowRight className="ml-2 h-4 w-4" />
|
||||
{t("common.tryNow")} <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -228,6 +222,9 @@ function FeaturesSection() {
|
||||
}
|
||||
|
||||
function BenefitsSection() {
|
||||
const { t } = useTranslation();
|
||||
const benefits = useBenefits();
|
||||
|
||||
return (
|
||||
<section className="py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
@@ -239,14 +236,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">
|
||||
Why Choose Mini Game AI?
|
||||
{t("home.benefits.title")}
|
||||
</h2>
|
||||
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
We understand the unique challenges of game development. Our tools are built to help
|
||||
you work faster and smarter.
|
||||
{t("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">Learn More About Us</Link>
|
||||
<Link href="/about">{t("common.learnMore")}</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
@@ -280,6 +276,9 @@ function BenefitsSection() {
|
||||
}
|
||||
|
||||
function PricingSection() {
|
||||
const { t } = useTranslation();
|
||||
const pricingPlans = usePricingPlans();
|
||||
|
||||
return (
|
||||
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
@@ -291,10 +290,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">
|
||||
Simple, Transparent Pricing
|
||||
{t("home.pricing.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Start free, scale as you grow. No hidden fees.
|
||||
{t("home.pricing.description")}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -310,7 +309,7 @@ function PricingSection() {
|
||||
<Card className={`relative h-full ${plan.popular ? "border-primary" : ""} xl:p-8 2xl:p-10`}>
|
||||
{plan.popular && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 xl:text-base 2xl:text-lg">
|
||||
Most Popular
|
||||
{plan.popular}
|
||||
</Badge>
|
||||
)}
|
||||
<CardHeader>
|
||||
@@ -346,6 +345,8 @@ function PricingSection() {
|
||||
}
|
||||
|
||||
function CTASection() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section className="py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
@@ -358,17 +359,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">
|
||||
Ready to Level Up?
|
||||
{t("home.cta.title")}
|
||||
</h2>
|
||||
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Join thousands of game developers building amazing games with our tools.
|
||||
{t("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">Get Started for Free</Link>
|
||||
<Link href="/register">{t("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">Contact Sales</Link>
|
||||
<Link href="/contact">{t("common.contactSales")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Sparkles, Github, Twitter } from "lucide-react";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ name: "Features", href: "/features" },
|
||||
{ name: "Pricing", href: "/pricing" },
|
||||
{ name: "API", href: "/api" },
|
||||
{ name: "Documentation", href: "/docs" },
|
||||
],
|
||||
tools: [
|
||||
{ name: "Video to Frames", href: "/tools/video-frames" },
|
||||
{ name: "Image Compression", href: "/tools/image-compress" },
|
||||
{ name: "Audio Compression", href: "/tools/audio-compress" },
|
||||
{ name: "AI Tools", href: "/tools/ai-tools" },
|
||||
],
|
||||
company: [
|
||||
{ name: "About", href: "/about" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
{ name: "Careers", href: "/careers" },
|
||||
{ name: "Contact", href: "/contact" },
|
||||
],
|
||||
legal: [
|
||||
{ name: "Privacy", href: "/privacy" },
|
||||
{ name: "Terms", href: "/terms" },
|
||||
{ name: "Cookie Policy", href: "/cookies" },
|
||||
],
|
||||
};
|
||||
function useFooterLinks() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
product: [
|
||||
{ name: t("common.features"), href: "/features" },
|
||||
{ name: t("nav.pricing"), href: "/pricing" },
|
||||
{ name: "API", href: "/api" },
|
||||
{ name: t("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" },
|
||||
],
|
||||
company: [
|
||||
{ name: t("nav.about"), href: "/about" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
{ name: "Careers", href: "/careers" },
|
||||
{ name: "Contact", href: "/contact" },
|
||||
],
|
||||
legal: [
|
||||
{ name: "Privacy", href: "/privacy" },
|
||||
{ name: "Terms", href: "/terms" },
|
||||
{ name: "Cookie Policy", href: "/cookies" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const socialLinks = [
|
||||
{ name: "Twitter", icon: Twitter, href: "https://twitter.com" },
|
||||
{ name: "GitHub", icon: Github, href: "https://github.com" },
|
||||
];
|
||||
|
||||
const sectionTitles: Record<string, string> = {
|
||||
product: "Product",
|
||||
tools: "Tools",
|
||||
company: "Company",
|
||||
legal: "Legal",
|
||||
};
|
||||
|
||||
export function Footer() {
|
||||
const { t } = useTranslation();
|
||||
const footerLinks = useFooterLinks();
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border/40 bg-background/50">
|
||||
<div className="container py-12 md:py-16">
|
||||
@@ -43,16 +60,16 @@ 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">Mini Game AI</span>
|
||||
<span className="text-xl font-bold">{t("common.appName")}</span>
|
||||
</Link>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
AI-powered tools for mini game developers. Process media files with ease.
|
||||
{t("footer.tagline")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Product</h3>
|
||||
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.product}</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.product.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -69,7 +86,7 @@ export function Footer() {
|
||||
|
||||
{/* Tools */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Tools</h3>
|
||||
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.tools}</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.tools.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -86,7 +103,7 @@ export function Footer() {
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Company</h3>
|
||||
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.company}</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -103,7 +120,7 @@ export function Footer() {
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Legal</h3>
|
||||
<h3 className="mb-4 text-sm font-semibold">{sectionTitles.legal}</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.legal.map((link) => (
|
||||
<li key={link.name}>
|
||||
@@ -122,7 +139,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()} Mini Game AI. All rights reserved.
|
||||
© {new Date().getFullYear()} {t("common.appName")}. All rights reserved.
|
||||
</p>
|
||||
<div className="mt-4 flex space-x-6 md:mt-0">
|
||||
{socialLinks.map((link) => {
|
||||
|
||||
@@ -5,19 +5,27 @@ import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// import { Button } from "@/components/ui/button"; // TODO: Uncomment when adding login/register buttons
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ name: "Tools", href: "/tools" },
|
||||
{ name: "Pricing", href: "/pricing" },
|
||||
{ name: "Docs", href: "/docs" },
|
||||
{ name: "About", href: "/about" },
|
||||
];
|
||||
function useNavItems() {
|
||||
const { t } = useTranslation();
|
||||
return [
|
||||
{ name: t("nav.tools"), href: "/tools/image-compress" },
|
||||
{ name: t("nav.pricing"), href: "/tools/video-frames" },
|
||||
{ name: t("nav.docs"), href: "/tools/audio-compress" },
|
||||
// Note: Temporarily redirecting to existing tool pages
|
||||
// TODO: Create dedicated pages for pricing, docs, about
|
||||
];
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const navItems = useNavItems();
|
||||
const { t } = useTranslation();
|
||||
|
||||
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">
|
||||
@@ -27,7 +35,7 @@ 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">Mini Game AI</span>
|
||||
<span className="text-xl font-bold">{t("common.appName")}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
@@ -48,12 +56,15 @@ export function Header() {
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="hidden md:flex md:items-center md:space-x-4">
|
||||
<LanguageSwitcher />
|
||||
{/* TODO: Create login/register pages
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
<Link href="/login">{t("common.signIn")}</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/register">Get Started</Link>
|
||||
<Link href="/register">{t("common.getStarted")}</Link>
|
||||
</Button>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
@@ -95,12 +106,15 @@ export function Header() {
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col space-y-2 pt-4">
|
||||
<LanguageSwitcher />
|
||||
{/* TODO: Create login/register pages
|
||||
<Button variant="ghost" size="sm" asChild className="w-full">
|
||||
<Link href="/login">Sign In</Link>
|
||||
<Link href="/login">{t("common.signIn")}</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild className="w-full">
|
||||
<Link href="/register">Get Started</Link>
|
||||
<Link href="/register">{t("common.getStarted")}</Link>
|
||||
</Button>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
51
src/components/layout/LanguageSwitcher.tsx
Normal file
51
src/components/layout/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslation, type Locale } from "@/lib/i18n";
|
||||
import { Check, Globe } from "lucide-react";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale, locales } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleLocaleChange = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<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 }]) => (
|
||||
<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 && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -9,41 +9,44 @@ import {
|
||||
Music,
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
CreditCard,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
items: [
|
||||
{ name: "Overview", href: "/dashboard", icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tools",
|
||||
items: [
|
||||
{ name: "Video to Frames", href: "/tools/video-frames", icon: Video },
|
||||
{ name: "Image Compression", href: "/tools/image-compress", icon: Image },
|
||||
{ name: "Audio Compression", href: "/tools/audio-compress", icon: Music },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI Tools",
|
||||
items: [
|
||||
{ name: "AI Image", href: "/tools/ai-image", icon: Sparkles },
|
||||
{ name: "AI Audio", href: "/tools/ai-audio", icon: Sparkles },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
items: [
|
||||
{ name: "Pricing", href: "/pricing", icon: CreditCard },
|
||||
{ name: "Settings", href: "/settings", icon: Settings },
|
||||
],
|
||||
},
|
||||
];
|
||||
function useSidebarNavItems() {
|
||||
const { t } = useTranslation();
|
||||
return [
|
||||
{
|
||||
title: t("nav.dashboard"),
|
||||
items: [
|
||||
{ name: t("nav.overview"), href: "/", icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("sidebar.aiTools"),
|
||||
items: [
|
||||
{ name: t("sidebar.aiImage"), href: "/tools/ai-tools", icon: Sparkles },
|
||||
{ name: t("sidebar.aiAudio"), href: "/tools/ai-tools", icon: Sparkles },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("nav.account"),
|
||||
items: [
|
||||
// TODO: Create pricing and settings pages
|
||||
// { name: t("nav.pricing"), href: "/pricing", icon: CreditCard },
|
||||
// { name: t("common.settings"), href: "/settings", icon: Settings },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
@@ -51,6 +54,7 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const sidebarNavItems = useSidebarNavItems();
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -61,7 +65,9 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
>
|
||||
<div className="h-full overflow-y-auto py-6 pr-4">
|
||||
<nav className="space-y-8 px-4">
|
||||
{sidebarNavItems.map((section) => (
|
||||
{sidebarNavItems
|
||||
.filter((section) => section.items.length > 0)
|
||||
.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h3 className="mb-4 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export interface ConfigOption {
|
||||
id: string;
|
||||
@@ -38,6 +39,8 @@ export function ConfigPanel({
|
||||
onReset,
|
||||
className,
|
||||
}: ConfigPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
@@ -50,7 +53,7 @@ export function ConfigPanel({
|
||||
</div>
|
||||
{onReset && (
|
||||
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||
Reset
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatFileSize, getFileExtension } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { UploadedFile } from "@/types";
|
||||
|
||||
interface FileUploaderProps {
|
||||
@@ -35,6 +36,8 @@ export function FileUploader({
|
||||
maxFiles = 10,
|
||||
disabled = false,
|
||||
}: FileUploaderProps) {
|
||||
const { t, plural } = useTranslation();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (disabled) return;
|
||||
@@ -85,14 +88,17 @@ export function FileUploader({
|
||||
<div className="mt-4">
|
||||
<p className="text-lg font-medium">
|
||||
{isDragActive
|
||||
? "Drop your files here"
|
||||
? t("uploader.dropActive")
|
||||
: isDragReject
|
||||
? "File type not accepted"
|
||||
: "Drag & drop files here"}
|
||||
? t("uploader.fileRejected")
|
||||
: t("uploader.dropFiles")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
or click to browse • Max {formatFileSize(maxSize)} • Up to {maxFiles} file
|
||||
{maxFiles > 1 ? "s" : ""}
|
||||
{t("uploader.browseFiles", {
|
||||
maxSize: formatFileSize(maxSize),
|
||||
maxFiles,
|
||||
file: plural("uploader.file", maxFiles),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { ProcessingProgress } from "@/types";
|
||||
|
||||
interface ProgressBarProps {
|
||||
@@ -29,6 +30,7 @@ const statusColors = {
|
||||
};
|
||||
|
||||
export function ProgressBar({ progress, className }: ProgressBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { status, progress: value, message, error } = progress;
|
||||
const showProgress = status === "uploading" || status === "processing";
|
||||
const Icon = statusIcons[status];
|
||||
@@ -46,11 +48,11 @@ export function ProgressBar({ progress, className }: ProgressBarProps) {
|
||||
statusColors[status]
|
||||
)}
|
||||
>
|
||||
{status === "idle" && "Ready to process"}
|
||||
{status === "uploading" && "Uploading..."}
|
||||
{status === "processing" && "Processing..."}
|
||||
{status === "completed" && "Completed!"}
|
||||
{status === "failed" && "Failed"}
|
||||
{status === "idle" && t("progress.status.idle")}
|
||||
{status === "uploading" && t("progress.status.uploading")}
|
||||
{status === "processing" && t("progress.status.processing")}
|
||||
{status === "completed" && t("progress.status.completed")}
|
||||
{status === "failed" && t("progress.status.failed")}
|
||||
</p>
|
||||
{showProgress && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { ProcessedFile } from "@/types";
|
||||
|
||||
interface ResultPreviewProps {
|
||||
@@ -21,6 +22,8 @@ export function ResultPreview({
|
||||
onShare,
|
||||
className,
|
||||
}: ResultPreviewProps) {
|
||||
const { t, plural } = useTranslation();
|
||||
|
||||
if (results.length === 0) return null;
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
@@ -36,7 +39,7 @@ export function ResultPreview({
|
||||
|
||||
if (metadata.compressionRatio) {
|
||||
badges.push({
|
||||
label: `Saved ${metadata.compressionRatio}%`,
|
||||
label: t("results.saved", { ratio: metadata.compressionRatio }),
|
||||
variant: "default" as const,
|
||||
});
|
||||
}
|
||||
@@ -58,9 +61,12 @@ export function ResultPreview({
|
||||
className={className}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">Processing Complete</h3>
|
||||
<h3 className="text-lg font-semibold">{t("results.processingComplete")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{results.length} file{results.length > 1 ? "s" : ""} ready for download
|
||||
{t("results.filesReady", {
|
||||
count: results.length,
|
||||
file: plural("results.file", results.length),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +121,7 @@ export function ResultPreview({
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onDownload(result.id)}
|
||||
title="Download"
|
||||
title={t("common.download")}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -124,7 +130,7 @@ export function ResultPreview({
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onShare(result.id)}
|
||||
title="Share"
|
||||
title={t("common.share")}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
191
src/components/ui/dropdown-menu.tsx
Normal file
191
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<span className="h-2 w-2 rounded-full bg-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
85
src/lib/i18n.ts
Normal file
85
src/lib/i18n.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import en from "@/locales/en.json";
|
||||
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: "🇨🇳" },
|
||||
};
|
||||
|
||||
const messages: Record<Locale, LocaleMessages> = { en, zh };
|
||||
|
||||
interface I18nState {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
function getNestedValue(obj: any, path: string): string | unknown {
|
||||
return path.split(".").reduce((acc, part) => acc?.[part], obj) || path;
|
||||
}
|
||||
|
||||
function interpolate(template: string, params: Record<string, string | number | unknown> = {}): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
const value = params[key];
|
||||
if (value === undefined || value === null) return `{{${key}}}`;
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export const useI18nStore = create<I18nState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
locale: "en",
|
||||
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);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "locale-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Convenience hook
|
||||
export function useTranslation() {
|
||||
const { locale, setLocale, t } = useI18nStore();
|
||||
|
||||
const plural = (key: string, count: number) => {
|
||||
const suffix = count === 1 ? "_one" : "_other";
|
||||
return t(`${key}${suffix}`, { count });
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
plural,
|
||||
locales,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for SSR
|
||||
export function getServerTranslations(locale: Locale = "en") {
|
||||
return {
|
||||
locale,
|
||||
t: (key: string, params?: Record<string, string | number>) => {
|
||||
const value = getNestedValue(messages[locale], key);
|
||||
if (typeof value !== "string") return value as string;
|
||||
if (!params || Object.keys(params).length === 0) return value;
|
||||
return interpolate(value, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
254
src/locales/en.json
Normal file
254
src/locales/en.json
Normal file
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "KYMR",
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"download": "Download",
|
||||
"share": "Share",
|
||||
"reset": "Reset",
|
||||
"submit": "Submit",
|
||||
"tryNow": "Try it now",
|
||||
"learnMore": "Learn More",
|
||||
"getStarted": "Get Started",
|
||||
"startBuilding": "Start Building",
|
||||
"viewPricing": "View Pricing",
|
||||
"contactSales": "Contact Sales",
|
||||
"signIn": "Sign In",
|
||||
"register": "Register",
|
||||
"features": "Features",
|
||||
"settings": "Settings",
|
||||
"processing": "Processing...",
|
||||
"uploading": "Uploading...",
|
||||
"completed": "Completed!",
|
||||
"failed": "Failed",
|
||||
"ready": "Ready to process",
|
||||
"file": "File",
|
||||
"files": "files"
|
||||
},
|
||||
"nav": {
|
||||
"tools": "Tools",
|
||||
"pricing": "Pricing",
|
||||
"docs": "Docs",
|
||||
"about": "About",
|
||||
"dashboard": "Dashboard",
|
||||
"overview": "Overview",
|
||||
"account": "Account"
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
"badge": "Media Processing Tools",
|
||||
"title": "Empowering Game Development",
|
||||
"description": "Video to frames, image compression, audio optimization. Everything you need to prepare game assets, in one place.",
|
||||
"stats": {
|
||||
"developers": "Developers",
|
||||
"filesProcessed": "Files Processed",
|
||||
"uptime": "Uptime"
|
||||
}
|
||||
},
|
||||
"featuresSection": {
|
||||
"title": "Everything You Need",
|
||||
"description": "Powerful tools designed specifically for game developers"
|
||||
},
|
||||
"tools": {
|
||||
"videoToFrames": {
|
||||
"title": "Video to Frames",
|
||||
"description": "Extract frames from videos with customizable frame rates. Perfect for sprite animations."
|
||||
},
|
||||
"imageCompression": {
|
||||
"title": "Image Compression",
|
||||
"description": "Optimize images for web and mobile without quality loss. Support for batch processing."
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "Audio Compression",
|
||||
"description": "Compress and convert audio files to various formats. Adjust bitrate and sample rate."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "More Tools",
|
||||
"description": "Additional utilities for game development. Coming soon."
|
||||
}
|
||||
},
|
||||
"benefits": {
|
||||
"title": "Why Choose KYMR?",
|
||||
"description": "We understand the unique challenges of game development. Our tools are built to help you work faster and smarter.",
|
||||
"lightningFast": {
|
||||
"title": "Lightning Fast",
|
||||
"description": "Process files in seconds with our optimized infrastructure."
|
||||
},
|
||||
"secure": {
|
||||
"title": "Secure & Private",
|
||||
"description": "Your files are encrypted and automatically deleted after processing."
|
||||
},
|
||||
"forDevelopers": {
|
||||
"title": "Built for Developers",
|
||||
"description": "API access, batch processing, and tools designed for game development workflows."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Simple, Transparent Pricing",
|
||||
"description": "Start free, scale as you grow. No hidden fees.",
|
||||
"plans": {
|
||||
"free": {
|
||||
"name": "Free",
|
||||
"price": "$0",
|
||||
"description": "Perfect for trying out",
|
||||
"features": ["10 processes per day", "50MB max file size", "Basic tools", "Community support"],
|
||||
"cta": "Get Started"
|
||||
},
|
||||
"pro": {
|
||||
"name": "Pro",
|
||||
"price": "$19",
|
||||
"period": "/month",
|
||||
"description": "For serious developers",
|
||||
"features": ["Unlimited processes", "500MB max file size", "All tools including AI", "Priority support", "API access"],
|
||||
"cta": "Start Free Trial",
|
||||
"popular": "Most Popular"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "Enterprise",
|
||||
"price": "Custom",
|
||||
"description": "For teams and businesses",
|
||||
"features": ["Everything in Pro", "Unlimited file size", "Custom integrations", "Dedicated support", "SLA guarantee"],
|
||||
"cta": "Contact Sales"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to Level Up?",
|
||||
"description": "Join thousands of game developers building amazing games with our tools.",
|
||||
"getStarted": "Get Started for Free"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"tools": "Tools",
|
||||
"aiTools": "More Tools",
|
||||
"videoToFrames": "Video to Frames",
|
||||
"imageCompression": "Image Compression",
|
||||
"audioCompression": "Audio Compression",
|
||||
"aiImage": "AI Image",
|
||||
"aiAudio": "AI Audio"
|
||||
},
|
||||
"uploader": {
|
||||
"dropFiles": "Drag & drop files here",
|
||||
"dropActive": "Drop your files here",
|
||||
"fileRejected": "File type not accepted",
|
||||
"browseFiles": "or click to browse • Max {{maxSize}} • Up to {{maxFiles}} {{file}}",
|
||||
"file_one": "file",
|
||||
"file_other": "files"
|
||||
},
|
||||
"progress": {
|
||||
"status": {
|
||||
"idle": "Ready to process",
|
||||
"uploading": "Uploading...",
|
||||
"processing": "Processing...",
|
||||
"completed": "Completed!",
|
||||
"failed": "Failed"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"imageCompression": {
|
||||
"title": "Compression Settings",
|
||||
"description": "Configure compression options",
|
||||
"quality": "Compression Quality",
|
||||
"qualityDescription": "Lower quality = smaller file size",
|
||||
"format": "Output Format",
|
||||
"formatDescription": "Convert to a different format (optional)",
|
||||
"formatOriginal": "Original",
|
||||
"formatJpeg": "JPEG",
|
||||
"formatPng": "PNG",
|
||||
"formatWebp": "WebP"
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "Export Settings",
|
||||
"description": "Configure how frames are extracted",
|
||||
"fps": "Frame Rate",
|
||||
"fpsDescription": "Number of frames to extract per second",
|
||||
"format": "Output Format",
|
||||
"formatDescription": "Image format for the extracted frames",
|
||||
"quality": "Quality",
|
||||
"qualityDescription": "Image quality (for JPEG and WebP)"
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "Audio Settings",
|
||||
"description": "Configure compression parameters",
|
||||
"bitrate": "Bitrate",
|
||||
"bitrateDescription": "Higher bitrate = better quality, larger file",
|
||||
"format": "Output Format",
|
||||
"formatDescription": "Target audio format",
|
||||
"sampleRate": "Sample Rate",
|
||||
"sampleRateDescription": "Audio sample rate in Hz",
|
||||
"channels": "Channels",
|
||||
"channelsDescription": "Audio channels",
|
||||
"stereo": "Stereo (2 channels)",
|
||||
"mono": "Mono (1 channel)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"imageCompression": {
|
||||
"title": "Image Compression",
|
||||
"description": "Optimize images for web and mobile without quality loss",
|
||||
"compressImages": "Compress Images",
|
||||
"features": "Features",
|
||||
"featureList": [
|
||||
"Batch processing - compress multiple images at once",
|
||||
"Smart compression - maintains visual quality",
|
||||
"Format conversion - PNG to JPEG, WebP, and more",
|
||||
"Up to 80% size reduction without quality loss"
|
||||
]
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "Video to Frames",
|
||||
"description": "Extract frames from videos with customizable settings",
|
||||
"processVideo": "Process Video",
|
||||
"howItWorks": "How it works",
|
||||
"steps": [
|
||||
"Upload your video file (MP4, MOV, AVI, etc.)",
|
||||
"Configure frame rate, format, and quality",
|
||||
"Click \"Process Video\" to start extraction",
|
||||
"Download the ZIP file with all frames"
|
||||
]
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "Audio Compression",
|
||||
"description": "Compress and convert audio files with quality control",
|
||||
"compressAudio": "Compress Audio",
|
||||
"supportedFormats": "Supported Formats",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A",
|
||||
"outputFormats": "MP3, AAC, OGG, FLAC"
|
||||
}
|
||||
},
|
||||
"processing": {
|
||||
"uploadingImages": "Uploading images...",
|
||||
"compressingImages": "Compressing images...",
|
||||
"uploadingVideo": "Uploading video...",
|
||||
"extractingFrames": "Extracting frames...",
|
||||
"uploadingAudio": "Uploading audio...",
|
||||
"compressingAudio": "Compressing audio...",
|
||||
"compressionComplete": "Compression complete!",
|
||||
"processingComplete": "Processing complete!",
|
||||
"compressionFailed": "Compression failed",
|
||||
"processingFailed": "Processing failed",
|
||||
"unknownError": "Unknown error",
|
||||
"uploadProgress": "Uploading... {{progress}}%",
|
||||
"compressProgress": "Compressing... {{progress}}%",
|
||||
"processProgress": "Processing... {{progress}}%"
|
||||
},
|
||||
"results": {
|
||||
"processingComplete": "Processing Complete",
|
||||
"filesReady": "{{count}} {{file}} ready for download",
|
||||
"file_one": "file",
|
||||
"file_other": "files",
|
||||
"saved": "Saved {{ratio}}%"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio."
|
||||
}
|
||||
}
|
||||
254
src/locales/zh.json
Normal file
254
src/locales/zh.json
Normal file
@@ -0,0 +1,254 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "KYMR",
|
||||
"loading": "加载中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"download": "下载",
|
||||
"share": "分享",
|
||||
"reset": "重置",
|
||||
"submit": "提交",
|
||||
"tryNow": "立即尝试",
|
||||
"learnMore": "了解更多",
|
||||
"getStarted": "开始使用",
|
||||
"startBuilding": "开始创作",
|
||||
"viewPricing": "查看价格",
|
||||
"contactSales": "联系销售",
|
||||
"signIn": "登录",
|
||||
"register": "注册",
|
||||
"features": "功能",
|
||||
"settings": "设置",
|
||||
"processing": "处理中...",
|
||||
"uploading": "上传中...",
|
||||
"completed": "已完成!",
|
||||
"failed": "失败",
|
||||
"ready": "准备处理",
|
||||
"file": "文件",
|
||||
"files": "文件"
|
||||
},
|
||||
"nav": {
|
||||
"tools": "工具",
|
||||
"pricing": "价格",
|
||||
"docs": "文档",
|
||||
"about": "关于",
|
||||
"dashboard": "仪表盘",
|
||||
"overview": "概览",
|
||||
"account": "账户"
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
"badge": "媒体处理工具",
|
||||
"title": "为小游戏开发提供全链路提效赋能",
|
||||
"description": "视频抽帧、图片压缩、音频优化。一站式游戏素材处理工具,让开发更高效。",
|
||||
"stats": {
|
||||
"developers": "开发者",
|
||||
"filesProcessed": "文件处理量",
|
||||
"uptime": "正常运行时间"
|
||||
}
|
||||
},
|
||||
"featuresSection": {
|
||||
"title": "您需要的一切",
|
||||
"description": "专为游戏开发者设计的强大工具"
|
||||
},
|
||||
"tools": {
|
||||
"videoToFrames": {
|
||||
"title": "视频抽帧",
|
||||
"description": "从视频中提取帧,可自定义帧率。非常适合精灵动画制作。"
|
||||
},
|
||||
"imageCompression": {
|
||||
"title": "图片压缩",
|
||||
"description": "为网页和移动端优化图片,不影响质量。支持批量处理。"
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "音频压缩",
|
||||
"description": "压缩并转换音频文件为多种格式。调整比特率和采样率。"
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "更多工具",
|
||||
"description": "更多游戏开发实用工具,敬请期待。"
|
||||
}
|
||||
},
|
||||
"benefits": {
|
||||
"title": "为什么选择 KYMR?",
|
||||
"description": "我们了解游戏开发的独特挑战。我们的工具帮助您更快速、更智能地工作。",
|
||||
"lightningFast": {
|
||||
"title": "极速处理",
|
||||
"description": "通过优化的基础设施,在几秒钟内处理文件。"
|
||||
},
|
||||
"secure": {
|
||||
"title": "安全私密",
|
||||
"description": "您的文件将被加密,处理完成后自动删除。"
|
||||
},
|
||||
"forDevelopers": {
|
||||
"title": "专为开发者打造",
|
||||
"description": "API 访问、批量处理,以及专为游戏开发工作流程设计的工具。"
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"title": "简单透明的定价",
|
||||
"description": "免费开始,随您增长。无隐藏费用。",
|
||||
"plans": {
|
||||
"free": {
|
||||
"name": "免费版",
|
||||
"price": "¥0",
|
||||
"description": "适合尝试使用",
|
||||
"features": ["每天 10 次处理", "最大 50MB 文件", "基础工具", "社区支持"],
|
||||
"cta": "开始使用"
|
||||
},
|
||||
"pro": {
|
||||
"name": "专业版",
|
||||
"price": "¥99",
|
||||
"period": "/月",
|
||||
"description": "适合专业开发者",
|
||||
"features": ["无限处理次数", "最大 500MB 文件", "所有工具包括 AI", "优先支持", "API 访问"],
|
||||
"cta": "免费试用",
|
||||
"popular": "最受欢迎"
|
||||
},
|
||||
"enterprise": {
|
||||
"name": "企业版",
|
||||
"price": "定制",
|
||||
"description": "适合团队和企业",
|
||||
"features": ["专业版所有功能", "无限文件大小", "定制集成", "专属支持", "SLA 保证"],
|
||||
"cta": "联系销售"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "准备好升级了吗?",
|
||||
"description": "加入数千名使用我们工具开发精彩游戏的开发者。",
|
||||
"getStarted": "免费开始"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"tools": "工具",
|
||||
"aiTools": "更多工具",
|
||||
"videoToFrames": "视频抽帧",
|
||||
"imageCompression": "图片压缩",
|
||||
"audioCompression": "音频压缩",
|
||||
"aiImage": "AI 图片",
|
||||
"aiAudio": "AI 音频"
|
||||
},
|
||||
"uploader": {
|
||||
"dropFiles": "拖拽文件到这里",
|
||||
"dropActive": "释放文件即可上传",
|
||||
"fileRejected": "不支持的文件类型",
|
||||
"browseFiles": "或点击选择文件 • 最大 {{maxSize}} • 最多 {{maxFiles}} 个{{file}}",
|
||||
"file_one": "文件",
|
||||
"file_other": "文件"
|
||||
},
|
||||
"progress": {
|
||||
"status": {
|
||||
"idle": "准备处理",
|
||||
"uploading": "上传中...",
|
||||
"processing": "处理中...",
|
||||
"completed": "已完成!",
|
||||
"failed": "失败"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"imageCompression": {
|
||||
"title": "压缩设置",
|
||||
"description": "配置压缩选项",
|
||||
"quality": "压缩质量",
|
||||
"qualityDescription": "质量越低 = 文件越小",
|
||||
"format": "输出格式",
|
||||
"formatDescription": "转换为其他格式(可选)",
|
||||
"formatOriginal": "原始",
|
||||
"formatJpeg": "JPEG",
|
||||
"formatPng": "PNG",
|
||||
"formatWebp": "WebP"
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "导出设置",
|
||||
"description": "配置帧提取方式",
|
||||
"fps": "帧率",
|
||||
"fpsDescription": "每秒提取的帧数",
|
||||
"format": "输出格式",
|
||||
"formatDescription": "提取帧的图片格式",
|
||||
"quality": "质量",
|
||||
"qualityDescription": "图片质量(适用于 JPEG 和 WebP)"
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "音频设置",
|
||||
"description": "配置压缩参数",
|
||||
"bitrate": "比特率",
|
||||
"bitrateDescription": "比特率越高 = 质量越好,文件越大",
|
||||
"format": "输出格式",
|
||||
"formatDescription": "目标音频格式",
|
||||
"sampleRate": "采样率",
|
||||
"sampleRateDescription": "音频采样率(Hz)",
|
||||
"channels": "声道",
|
||||
"channelsDescription": "音频声道",
|
||||
"stereo": "立体声(2 声道)",
|
||||
"mono": "单声道(1 声道)"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"imageCompression": {
|
||||
"title": "图片压缩",
|
||||
"description": "为网页和移动端优化图片,不影响质量",
|
||||
"compressImages": "压缩图片",
|
||||
"features": "功能特点",
|
||||
"featureList": [
|
||||
"批量处理 - 一次压缩多张图片",
|
||||
"智能压缩 - 保持视觉质量",
|
||||
"格式转换 - PNG 转 JPEG、WebP 等",
|
||||
"高达 80% 的压缩率且不影响质量"
|
||||
]
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "视频抽帧",
|
||||
"description": "从视频中提取帧,可自定义设置",
|
||||
"processVideo": "处理视频",
|
||||
"howItWorks": "工作原理",
|
||||
"steps": [
|
||||
"上传视频文件(MP4、MOV、AVI 等)",
|
||||
"配置帧率、格式和质量",
|
||||
"点击「处理视频」开始提取",
|
||||
"下载包含所有帧的 ZIP 文件"
|
||||
]
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "音频压缩",
|
||||
"description": "压缩并转换音频文件,可控质量",
|
||||
"compressAudio": "压缩音频",
|
||||
"supportedFormats": "支持的格式",
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A",
|
||||
"outputFormats": "MP3、AAC、OGG、FLAC"
|
||||
}
|
||||
},
|
||||
"processing": {
|
||||
"uploadingImages": "上传图片中...",
|
||||
"compressingImages": "压缩图片中...",
|
||||
"uploadingVideo": "上传视频中...",
|
||||
"extractingFrames": "提取帧中...",
|
||||
"uploadingAudio": "上传音频中...",
|
||||
"compressingAudio": "压缩音频中...",
|
||||
"compressionComplete": "压缩完成!",
|
||||
"processingComplete": "处理完成!",
|
||||
"compressionFailed": "压缩失败",
|
||||
"processingFailed": "处理失败",
|
||||
"unknownError": "未知错误",
|
||||
"uploadProgress": "上传中... {{progress}}%",
|
||||
"compressProgress": "压缩中... {{progress}}%",
|
||||
"processProgress": "处理中... {{progress}}%"
|
||||
},
|
||||
"results": {
|
||||
"processingComplete": "处理完成",
|
||||
"filesReady": "{{count}} 个{{file}}可下载",
|
||||
"file_one": "文件",
|
||||
"file_other": "文件",
|
||||
"saved": "节省 {{ratio}}%"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user