254 lines
17 KiB
TypeScript
254 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|