基于 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>
153 lines
5.1 KiB
TypeScript
153 lines
5.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|