feat: 实现 Mini Game AI 工具平台

基于 Next.js 15、React 19 和 TypeScript 构建面向小游戏开发者的 AI 赋能工具平台。

主要功能:
- 首页:包含 Hero、功能展示、优势介绍、定价和 CTA 区域
- 三大核心工具:视频转序列帧、图片压缩、音频压缩
- 响应式布局:包含顶部导航、页脚和侧边栏
- 文件上传:支持拖拽上传,使用 react-dropzone
- 进度追踪:实时显示上传和处理进度
- 可配置工具:每个工具都支持自定义参数配置
- 结果预览:支持下载处理后的文件
- 4K 优化:针对大屏幕优化的响应式设计
- API 路由:文件上传和处理的模拟实现

技术栈:
- Next.js 15 (App Router)
- React 19
- TypeScript (严格模式)
- Tailwind CSS(自定义 4K 断点)
- shadcn/ui 组件库
- Framer Motion 动画
- Zustand 状态管理

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 22:26:55 +08:00
parent 9529a684a1
commit a7449bf49b
40 changed files with 10963 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
"use client";
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { motion, AnimatePresence } from "framer-motion";
import { Upload, File, X, FileVideo, FileImage, Music } from "lucide-react";
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 type { UploadedFile } from "@/types";
interface FileUploaderProps {
onFilesDrop: (files: File[]) => void;
files: UploadedFile[];
onRemoveFile: (id: string) => void;
accept?: Record<string, string[]>;
maxSize?: number;
maxFiles?: number;
disabled?: boolean;
}
const defaultAccept = {
"image/*": [".png", ".jpg", ".jpeg", ".webp"],
"video/*": [".mp4", ".mov", ".avi", ".webm"],
"audio/*": [".mp3", ".wav", ".ogg", ".aac"],
};
export function FileUploader({
onFilesDrop,
files,
onRemoveFile,
accept = defaultAccept,
maxSize = 50 * 1024 * 1024, // 50MB
maxFiles = 10,
disabled = false,
}: FileUploaderProps) {
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (disabled) return;
onFilesDrop(acceptedFiles);
},
[onFilesDrop, disabled]
);
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop,
accept,
maxSize,
maxFiles,
disabled,
multiple: maxFiles > 1,
});
const getFileIcon = (file: File) => {
if (file.type.startsWith("image/")) return FileImage;
if (file.type.startsWith("video/")) return FileVideo;
if (file.type.startsWith("audio/")) return Music;
return File;
};
return (
<div className="space-y-4">
{/* Dropzone */}
<div {...getRootProps()}>
<motion.div
whileHover={disabled ? {} : { scale: 1.01 }}
whileTap={disabled ? {} : { scale: 0.99 }}
className={`
relative cursor-pointer rounded-lg border-2 border-dashed p-12 text-center transition-all
${
isDragActive
? "border-primary bg-primary/5"
: isDragReject
? "border-destructive bg-destructive/5"
: "border-border hover:border-primary/50 hover:bg-accent/5"
}
${disabled ? "cursor-not-allowed opacity-50" : ""}
`}
>
<input {...getInputProps()} />
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Upload className="h-8 w-8 text-primary" />
</div>
<div className="mt-4">
<p className="text-lg font-medium">
{isDragActive
? "Drop your files here"
: isDragReject
? "File type not accepted"
: "Drag & drop files here"}
</p>
<p className="mt-2 text-sm text-muted-foreground">
or click to browse Max {formatFileSize(maxSize)} Up to {maxFiles} file
{maxFiles > 1 ? "s" : ""}
</p>
</div>
</motion.div>
</div>
{/* File List */}
<AnimatePresence>
{files.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-2"
>
{files.map((file, index) => {
const Icon = getFileIcon(file.file);
return (
<motion.div
key={file.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.05 }}
>
<Card>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} {getFileExtension(file.name).toUpperCase()}
</p>
</div>
<Badge variant="secondary" className="shrink-0">
{file.file.type.split("/")[1].toUpperCase()}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => onRemoveFile(file.id)}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</CardContent>
</Card>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}