ai-chatbot/components/canvas/VideoCanvasHome.tsx

254 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useSWR from "swr";
import {
ArrowLeft, Clapperboard, FileText, Film, FolderOpen, Image as ImageIcon,
LayoutGrid, MoreHorizontal, Music2, Plus, Scissors, Sparkles, Video,
WandSparkles, Zap,
} from "lucide-react";
import { toast } from "sonner";
import { filterCanvasModels, type CanvasModelDescriptor } from "@/lib/video-canvas/model-matching";
export type VideoCanvasHomeProject = {
id: string;
title: string;
thumbnail: string | null;
createdAt: string;
updatedAt: string;
};
type Section = "home" | "projects";
type Starter = "image" | "video" | "audio" | "script" | "smart-edit";
type BrandingSettings = {
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
};
const quickStarts: Array<{ label: string; icon: typeof Video; starter: Starter; mode?: string; model?: string }> = [
{ label: "文生视频", icon: WandSparkles, starter: "video", mode: "text-to-video" },
{ label: "首帧视频", icon: Video, starter: "video", mode: "first-frame" },
{ label: "多参考视频", icon: Film, starter: "video", mode: "reference-images" },
{ label: "视频生成", icon: Clapperboard, starter: "video" },
{ label: "图片生成", icon: ImageIcon, starter: "image" },
{ label: "音频生成", icon: Music2, starter: "audio" },
{ label: "剧本生成", icon: FileText, starter: "script" },
{ label: "智能剪辑", icon: Scissors, starter: "smart-edit" },
];
function projectHref(id: string, starter?: Starter, mode?: string, model?: string) {
const params = new URLSearchParams({ mode: "video", workspaceType: "video" });
if (starter) params.set("starter", starter);
if (mode) params.set("starterMode", mode);
if (model) params.set("starterModel", model);
return `/canvas/${encodeURIComponent(id)}?${params.toString()}`;
}
function projectDate(value: string) {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit",
}).formatToParts(new Date(value));
const get = (name: string) => parts.find(part => part.type === name)?.value || "";
return `${get("year")}-${get("month")}-${get("day")}`;
}
function projectTitle(value: string) {
return !value || value === "未命名视频画布" ? "未命名" : value;
}
export function VideoCanvasHome({
initialHasMore, initialProjects, siteLogo, siteLogoDark, siteName, userName,
}: {
initialHasMore: boolean;
initialProjects: VideoCanvasHomeProject[];
siteLogo: string | null;
siteLogoDark: string | null;
siteName: string;
userName: string;
}) {
const router = useRouter();
const [section, setSection] = useState<Section>("home");
const [projects, setProjects] = useState(initialProjects);
const [hasMore, setHasMore] = useState(initialHasMore);
const [loadingMore, setLoadingMore] = useState(false);
const [creating, setCreating] = useState(false);
const [menuId, setMenuId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const { data: branding } = useSWR<BrandingSettings>(
"/api/site-settings?scope=branding",
async (url: string) => {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error("品牌信息不可用");
return response.json();
},
{ refreshInterval: 30_000, revalidateOnFocus: true },
);
const brandName = String(branding?.siteName ?? siteName).trim() || "AI Chatbot";
const brandLogo = (branding ? branding.siteLogoDark : siteLogoDark)?.trim()
|| (branding ? branding.siteLogo : siteLogo)?.trim();
const { data: membership } = useSWR<{ summary?: { currentCredits?: number } }>(
"/api/user/membership-summary",
async (url: string) => {
const response = await fetch(url);
if (!response.ok) throw new Error("积分信息不可用");
return response.json();
},
);
const { data: modelData } = useSWR<{ models?: CanvasModelDescriptor[] }>("/api/models", async (url: string) => {
const response = await fetch(url);
if (!response.ok) throw new Error("模型目录不可用");
return response.json();
});
const availableVideoModels = filterCanvasModels(modelData?.models || [], "video", "text-to-video").slice(0, 3);
const shortcuts = quickStarts.map((shortcut, index) => {
const model = index < 3 ? availableVideoModels[index] : undefined;
return model ? {
...shortcut,
label: model.displayName || model.modelId || shortcut.label,
model: model.modelId || model.id,
mode: "text-to-video",
} : shortcut;
});
useEffect(() => {
let active = true;
async function refreshRecent() {
try {
const response = await fetch("/api/canvas?workspaceType=video&limit=24&page=1", { cache: "no-store" });
const result = await response.json();
if (!active || !response.ok || !result.success) return;
setProjects(current => result.data.map((item: VideoCanvasHomeProject) => ({
...item,
thumbnail: item.thumbnail || current.find(existing => existing.id === item.id)?.thumbnail || null,
})));
setHasMore(Boolean(result.pagination?.hasMore));
} catch {
// The server-rendered project list remains available if refreshing fails.
}
}
void refreshRecent();
return () => { active = false; };
}, []);
async function createProject(starter?: Starter, mode?: string, model?: string) {
if (creating) return;
setCreating(true);
try {
const response = await fetch("/api/canvas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ workspaceType: "video", title: "未命名视频画布" }),
});
const result = await response.json();
if (!response.ok || !result.success || !result.data?.id) throw new Error(result.error || "创建项目失败");
setProjects(current => [{
id: result.data.id,
title: result.data.title || "未命名视频画布",
thumbnail: null,
createdAt: result.data.createdAt || new Date().toISOString(),
updatedAt: result.data.updatedAt || new Date().toISOString(),
}, ...current]);
router.push(projectHref(result.data.id, starter, mode, model));
} catch (error) {
toast.error(error instanceof Error ? error.message : "创建项目失败,请重试");
setCreating(false);
}
}
async function loadMore() {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
try {
const page = Math.floor(projects.length / 24) + 1;
const response = await fetch(`/api/canvas?workspaceType=video&limit=24&page=${page}`, { cache: "no-store" });
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || "加载项目失败");
setProjects(current => [...current, ...result.data.filter((item: VideoCanvasHomeProject) => !current.some(existing => existing.id === item.id))]);
setHasMore(Boolean(result.pagination?.hasMore));
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载项目失败");
} finally {
setLoadingMore(false);
}
}
async function saveTitle(id: string) {
const title = draftTitle.trim();
if (!title) { toast.error("项目名称不能为空"); return; }
try {
const response = await fetch(`/api/canvas/${encodeURIComponent(id)}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, workspaceType: "video" }),
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || "重命名失败");
setProjects(current => current.map(item => item.id === id ? { ...item, title, updatedAt: result.data.updatedAt || item.updatedAt } : item));
setEditingId(null);
toast.success("项目已重命名");
} catch (error) {
toast.error(error instanceof Error ? error.message : "重命名失败");
}
}
const visibleProjects = section === "home" ? projects.slice(0, 4) : projects;
return (
<div className="flex h-svh min-w-0 overflow-hidden bg-[#191919] text-[#f7f7f7]">
<aside className="flex w-[210px] shrink-0 flex-col border-r border-white/[0.06] bg-[#1c1c1c] px-3 py-[18px] max-sm:w-[60px] max-sm:px-2">
<a aria-label={`返回${brandName}首页`} className="mb-4 flex h-7 min-w-0 items-center gap-2 px-2 text-[18px] font-semibold" href="/">
{brandLogo ? <img alt="" className="size-6 shrink-0 object-contain" src={brandLogo} /> : <span className="flex size-5 shrink-0 items-center justify-center rounded bg-white text-[#191919]"><Film className="size-3.5" /></span>}
<span className="truncate max-sm:hidden">{brandName}</span>
</a>
<button className="flex h-[33px] items-center gap-2 rounded-[6px] bg-[#15bde4] px-2.5 text-left text-[13px] font-medium text-black hover:bg-[#3ed0ed] disabled:opacity-55" disabled={creating} onClick={() => void createProject()} type="button">
<Plus className="size-4" /><span className="max-sm:hidden">{creating ? "正在创建…" : "新建项目"}</span>
</button>
<div className="mt-5 space-y-1 border-b border-white/[0.06] pb-4">
<button className="flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] text-white/75 hover:bg-white/[0.06]" onClick={() => void createProject("video")} type="button"><Sparkles className="size-4" /><span className="max-sm:hidden">视频创作</span></button>
<button className="flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] text-white/75 hover:bg-white/[0.06]" onClick={() => void createProject("smart-edit")} type="button"><Scissors className="size-4" /><span className="max-sm:hidden">智能剪辑</span></button>
</div>
<nav aria-label="视频画布导航" className="mt-3 space-y-1">
<button aria-current={section === "home" ? "page" : undefined} className={`flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] ${section === "home" ? "bg-[#282828] text-white" : "text-white/75 hover:bg-white/[0.06]"}`} onClick={() => setSection("home")} type="button"><LayoutGrid className="size-4" /><span className="max-sm:hidden">首页</span></button>
<button aria-current={section === "projects" ? "page" : undefined} className={`flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-[12px] ${section === "projects" ? "bg-[#282828] text-white" : "text-white/75 hover:bg-white/[0.06]"}`} onClick={() => setSection("projects")} type="button"><FolderOpen className="size-4" /><span className="max-sm:hidden">项目</span></button>
<a className="flex h-8 items-center gap-2 rounded-md px-2.5 text-[12px] text-white/75 hover:bg-white/[0.06]" href="/gallery"><ImageIcon className="size-4" /><span className="max-sm:hidden">资产</span></a>
</nav>
<a aria-label="返回生图画布首页" className="mt-3 flex h-8 items-center gap-2 rounded-md border border-white/10 px-2.5 text-[12px] text-white/75 hover:bg-white/[0.06] hover:text-white" href="/" title="返回生图画布首页"><ArrowLeft className="size-4 shrink-0" /><span className="truncate max-sm:hidden">返回生图画布首页</span></a>
<div className="mt-auto flex items-center gap-2 border-t border-white/[0.06] px-2 pt-4 text-[12px] text-white/70"><span className="flex size-7 items-center justify-center rounded-full bg-[#414a62] font-medium text-white">{(userName || "用").slice(0, 1)}</span><span className="truncate max-sm:hidden">{userName || "我的工作区"}</span></div>
</aside>
<main className="min-w-0 flex-1 overflow-y-auto">
<header className="flex h-[46px] items-center justify-end gap-2 border-b border-white/[0.04] px-5 md:px-9">
<span className="flex h-8 items-center gap-1 rounded-lg border border-white/10 px-3 text-[12px] text-white/75"><Zap className="size-3.5" />{membership?.summary?.currentCredits ?? "—"}</span>
<span aria-label={userName || "当前用户"} className="flex size-8 items-center justify-center rounded-full bg-[#414a62] text-[12px] font-medium">{(userName || "用").slice(0, 1)}</span>
</header>
<div className="mx-auto max-w-[1500px] px-5 pb-12 pt-[10px] md:px-9">
{section === "home" ? <>
<button aria-label="新建画布创作" className="flex h-[140px] w-full flex-col items-center justify-center gap-3 rounded-[12px] border border-white/[0.14] bg-[radial-gradient(circle_at_center,rgba(255,255,255,.045),transparent_65%),radial-gradient(#5c5c5c_0.8px,transparent_0.8px)] bg-[length:100%_100%,18px_18px] text-[14px] font-semibold hover:border-white/30" disabled={creating} onClick={() => void createProject()} type="button">
<span className="flex h-[48px] w-[80px] items-center justify-center rounded-[15px] bg-[linear-gradient(#fff,#e9e9e9)] text-black shadow-[0_8px_25px_rgba(255,255,255,.1)]"><Plus className="size-8 stroke-[1.8]" /></span>
新建画布创作
</button>
<div className="mt-7 grid grid-cols-4 gap-3 lg:grid-cols-8 lg:gap-5">
{shortcuts.map(({ label, icon: Icon, starter, mode, model }, index) => <button className="group min-w-0 text-center disabled:opacity-50" disabled={creating} key={`${index}-${label}`} onClick={() => void createProject(starter, mode, model)} title={label} type="button"><span className="mx-auto flex h-[50px] w-[86px] max-w-full items-center justify-center rounded-[16px] bg-[#262626] transition-colors group-hover:bg-[#343434]"><Icon className="size-5 text-white/90" /></span><span className="mt-2 block truncate text-[12px] font-medium">{label}</span></button>)}
</div>
</> : null}
<div className={section === "home" ? "mt-10" : "mt-1"}>
<div className="mb-4 flex items-center justify-between"><h1 className="text-[14px] font-semibold">{section === "home" ? "最近项目" : "全部项目"}</h1>{section === "home" && projects.length > 0 ? <button className="text-[12px] text-white/75 hover:text-white" onClick={() => setSection("projects")} type="button">查看全部 ›</button> : null}</div>
{visibleProjects.length ? <div className={`grid grid-cols-1 gap-3 ${section === "home" ? "sm:grid-cols-2 lg:grid-cols-4" : "sm:grid-cols-2"}`}>{visibleProjects.map(project => <div className="group relative flex h-[74px] min-w-0 items-center gap-3 rounded-[24px] border border-white/[0.09] bg-[#202020] pr-11 transition-colors hover:border-white/[0.19] hover:bg-[#292929]" key={project.id}>
<button aria-label={`打开项目 ${projectTitle(project.title)}`} className="absolute inset-0 rounded-[24px]" onClick={() => router.push(projectHref(project.id))} type="button" />
<span className="pointer-events-none z-[1] ml-1 flex h-[66px] w-[88px] shrink-0 items-center justify-center overflow-hidden rounded-[20px] bg-[#292929] text-white/30">{project.thumbnail ? <img alt="" className="size-full object-cover" src={project.thumbnail} /> : <Sparkles className="size-5" />}</span>
<span className="pointer-events-none z-[1] min-w-0 flex-1"><span className="block truncate text-[13px] font-semibold">{projectTitle(project.title)}</span><span className="mt-1 block text-[11px] text-white/50">{projectDate(project.updatedAt)}</span></span>
<button aria-label={`项目 ${projectTitle(project.title)} 的更多操作`} aria-expanded={menuId === project.id} className="absolute right-4 top-4 z-[2] flex size-7 items-center justify-center rounded-lg text-white/60 opacity-0 hover:bg-white/10 hover:text-white focus:opacity-100 group-hover:opacity-100" onClick={() => setMenuId(current => current === project.id ? null : project.id)} type="button"><MoreHorizontal className="size-4" /></button>
{menuId === project.id ? <div className="absolute right-3 top-11 z-[4] min-w-[110px] rounded-lg border border-white/10 bg-[#303030] p-1 shadow-xl"><button className="w-full rounded px-3 py-2 text-left text-[12px] hover:bg-white/10" onClick={() => { setEditingId(project.id); setDraftTitle(projectTitle(project.title)); setMenuId(null); }} type="button">重命名</button></div> : null}
{editingId === project.id ? <form className="absolute inset-1 z-[5] flex items-center gap-2 rounded-[20px] bg-[#292929] px-3" onSubmit={event => { event.preventDefault(); void saveTitle(project.id); }}><input aria-label="项目名称" autoFocus className="min-w-0 flex-1 rounded-md border border-white/20 bg-[#202020] px-3 py-2 text-[13px] outline-none focus:border-white/60" maxLength={256} onChange={event => setDraftTitle(event.target.value)} value={draftTitle} /><button className="text-[12px] text-cyan-300" type="submit">保存</button><button className="text-[12px] text-white/50" onClick={() => setEditingId(null)} type="button">取消</button></form> : null}
</div>)}</div> : <div className="rounded-2xl border border-dashed border-white/10 px-5 py-14 text-center text-[13px] text-white/55">还没有视频项目,点击上方“新建画布创作”开始。</div>}
{section === "projects" && hasMore ? <button className="mx-auto mt-6 block rounded-lg border border-white/15 px-5 py-2 text-[12px] text-white/70 hover:bg-white/10 disabled:opacity-50" disabled={loadingMore} onClick={() => void loadMore()} type="button">{loadingMore ? "加载中…" : "加载更多"}</button> : null}
</div>
</div>
</main>
</div>
);
}