- Add canvas project and node database tables - Create canvas workspace with drag, resize, zoom and pan - Add ImageNode with background removal, enhance, extend, angle edit, scene fusion - Add VideoNode with playback controls - Add TextNode for text editing - Integrate AI image generation API (Gemini/Alibaba) - Implement viewport culling for performance optimization - Add canvas entry in top navigation bar
290 lines
9.9 KiB
TypeScript
290 lines
9.9 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Plus, LayoutGrid, Trash2, Edit2, MoreVertical, Clock, Loader2 } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { toast } from "sonner";
|
||
import { canvasService } from "@/services/canvas";
|
||
|
||
interface CanvasProject {
|
||
id: string;
|
||
title: string;
|
||
description?: string;
|
||
thumbnail?: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export default function CanvasListPage() {
|
||
const router = useRouter();
|
||
const [projects, setProjects] = useState<CanvasProject[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isCreating, setIsCreating] = useState(false);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [projectToDelete, setProjectToDelete] = useState<CanvasProject | null>(null);
|
||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||
const [projectToRename, setProjectToRename] = useState<CanvasProject | null>(null);
|
||
const [newTitle, setNewTitle] = useState("");
|
||
|
||
// 加载画布列表
|
||
const loadProjects = async () => {
|
||
setIsLoading(true);
|
||
const result = await canvasService.getProjects();
|
||
if (result.success && result.data) {
|
||
setProjects(result.data);
|
||
} else {
|
||
toast.error(result.error || "加载失败");
|
||
}
|
||
setIsLoading(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadProjects();
|
||
}, []);
|
||
|
||
// 创建新画布
|
||
const handleCreateProject = async () => {
|
||
setIsCreating(true);
|
||
const result = await canvasService.createProject({
|
||
title: "未命名画布",
|
||
});
|
||
|
||
if (result.success && result.data) {
|
||
toast.success("创建成功");
|
||
router.push(`/canvas/${result.data.id}`);
|
||
} else {
|
||
toast.error(result.error || "创建失败");
|
||
setIsCreating(false);
|
||
}
|
||
};
|
||
|
||
// 删除画布
|
||
const handleDeleteProject = async () => {
|
||
if (!projectToDelete) return;
|
||
|
||
const result = await canvasService.deleteProject(projectToDelete.id);
|
||
if (result.success) {
|
||
toast.success("删除成功");
|
||
setProjects(projects.filter(p => p.id !== projectToDelete.id));
|
||
} else {
|
||
toast.error(result.error || "删除失败");
|
||
}
|
||
setDeleteDialogOpen(false);
|
||
setProjectToDelete(null);
|
||
};
|
||
|
||
// 重命名画布
|
||
const handleRenameProject = async () => {
|
||
if (!projectToRename || !newTitle.trim()) return;
|
||
|
||
const result = await canvasService.updateProject(projectToRename.id, {
|
||
title: newTitle.trim(),
|
||
});
|
||
|
||
if (result.success) {
|
||
toast.success("重命名成功");
|
||
setProjects(projects.map(p =>
|
||
p.id === projectToRename.id ? { ...p, title: newTitle.trim() } : p
|
||
));
|
||
} else {
|
||
toast.error(result.error || "重命名失败");
|
||
}
|
||
setRenameDialogOpen(false);
|
||
setProjectToRename(null);
|
||
setNewTitle("");
|
||
};
|
||
|
||
// 格式化时间
|
||
const formatDate = (dateString: string) => {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleDateString("zh-CN", {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* 页面标题 */}
|
||
<div className="flex items-center justify-between p-6 border-b">
|
||
<div className="flex items-center gap-3">
|
||
<LayoutGrid className="h-6 w-6 text-purple-500" />
|
||
<h1 className="text-2xl font-bold">无限画布</h1>
|
||
</div>
|
||
<Button onClick={handleCreateProject} disabled={isCreating}>
|
||
{isCreating ? (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
)}
|
||
新建画布
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 画布列表 */}
|
||
<div className="flex-1 overflow-auto p-6">
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center h-64">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : projects.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||
<LayoutGrid className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||
<h3 className="text-lg font-medium mb-2">还没有画布</h3>
|
||
<p className="text-muted-foreground mb-4">
|
||
创建一个新画布,开始你的创作之旅
|
||
</p>
|
||
<Button onClick={handleCreateProject} disabled={isCreating}>
|
||
{isCreating ? (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
)}
|
||
新建画布
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||
{projects.map((project) => (
|
||
<Card
|
||
key={project.id}
|
||
className="group cursor-pointer hover:shadow-lg transition-shadow"
|
||
onClick={() => router.push(`/canvas/${project.id}`)}
|
||
>
|
||
<CardHeader className="p-4 pb-2">
|
||
<div className="flex items-start justify-between">
|
||
<CardTitle className="text-base font-medium line-clamp-1">
|
||
{project.title}
|
||
</CardTitle>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<MoreVertical className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setProjectToRename(project);
|
||
setNewTitle(project.title);
|
||
setRenameDialogOpen(true);
|
||
}}
|
||
>
|
||
<Edit2 className="h-4 w-4 mr-2" />
|
||
重命名
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="text-destructive"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setProjectToDelete(project);
|
||
setDeleteDialogOpen(true);
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-4 pt-0">
|
||
<div className="aspect-video bg-muted rounded-md flex items-center justify-center overflow-hidden">
|
||
{project.thumbnail ? (
|
||
<img
|
||
src={project.thumbnail}
|
||
alt={project.title}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<LayoutGrid className="h-12 w-12 text-muted-foreground/30" />
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
<CardFooter className="p-4 pt-0">
|
||
<div className="flex items-center text-xs text-muted-foreground">
|
||
<Clock className="h-3 w-3 mr-1" />
|
||
{formatDate(project.updatedAt)}
|
||
</div>
|
||
</CardFooter>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 删除确认对话框 */}
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>确认删除</DialogTitle>
|
||
<DialogDescription>
|
||
确定要删除画布 "{projectToDelete?.title}" 吗?此操作无法撤销。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button variant="destructive" onClick={handleDeleteProject}>
|
||
删除
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 重命名对话框 */}
|
||
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>重命名画布</DialogTitle>
|
||
</DialogHeader>
|
||
<Input
|
||
value={newTitle}
|
||
onChange={(e) => setNewTitle(e.target.value)}
|
||
placeholder="输入新名称"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
handleRenameProject();
|
||
}
|
||
}}
|
||
/>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={handleRenameProject} disabled={!newTitle.trim()}>
|
||
确定
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|