Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-short-drama-entry.tsx

208 lines
16 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.
import { Fragment, type CSSProperties, type ReactNode } from "react";
import { Dropdown } from "antd";
import { AlignLeft, ArrowRight, Bot, Check, ChevronDown, ChevronUp, Clapperboard, FolderKanban, Images, MoreHorizontal, Palette, Pencil, Plus, Sparkles, Type, Upload, X } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasShortDramaProgress, CanvasShortDramaStepId } from "@/lib/canvas/canvas-short-drama";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
export function CanvasLinkedProjectEmptyState({ projectName, hasChapter, onAddFirstChapter, onOpenAssets, onAddText }: { projectName: string; hasChapter: boolean; onAddFirstChapter: () => void; onOpenAssets: () => void; onAddText: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-16 pt-20">
<div className="pointer-events-auto w-full max-w-[440px] rounded-lg border p-3 shadow-sm backdrop-blur" data-canvas-no-zoom style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="flex items-center gap-2.5"><span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}><FolderKanban className="size-4" /></span><div className="min-w-0"><h2 className="truncate text-sm font-semibold">{projectName}</h2><p className="mt-0.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>项目画布为空</p></div></div>
<div className="mt-3 grid grid-cols-3 gap-1.5">
<button type="button" disabled={!hasChapter} onClick={onAddFirstChapter} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[var(--fs-label)] font-medium disabled:opacity-35" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Plus className="size-3.5 shrink-0" /><span className="truncate">添加首章</span></button>
<button type="button" onClick={onOpenAssets} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[var(--fs-label)] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Images className="size-3.5 shrink-0" /><span className="truncate">项目资产</span></button>
<button type="button" onClick={onAddText} className="flex h-9 min-w-0 items-center justify-center gap-1 rounded-md border px-2 text-[var(--fs-label)] font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Type className="size-3.5 shrink-0" /><span className="truncate">新建文本</span></button>
</div>
</div>
</div>
);
}
export function CanvasShortDramaEmptyState({ onCreatePipeline, onOpenAgent, onUpload, onAddText, onAddScript }: {
onCreatePipeline: () => void;
onOpenAgent: () => void;
onUpload: () => void;
onAddText: () => void;
onAddScript: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const focusStyle = { "--tw-ring-color": theme.accent.primary } as CSSProperties;
return (
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-20 pt-24">
<div className="pointer-events-auto w-full max-w-[760px]" data-canvas-no-zoom>
<div className="mb-4 text-center">
<h2 className="text-lg font-semibold">从哪里开始?</h2>
<p className="mt-1 text-sm" style={{ color: theme.node.muted }}>选择一条主路径,之后仍可随时切换。</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<PathCard
icon={<Clapperboard className="size-5" />}
title="自己创作"
description="搭好短剧骨架,再逐镜头编辑和生成。"
action="创建短剧流水线"
accent={theme.accent.primary}
theme={theme}
focusStyle={focusStyle}
onClick={onCreatePipeline}
/>
<PathCard
icon={<Bot className="size-5" />}
title="交给 Agent"
description="用一句话描述题材、角色和核心冲突。"
action="一句话生成影视项目"
accent={theme.node.activeStroke}
theme={theme}
focusStyle={focusStyle}
onClick={onOpenAgent}
/>
</div>
<div className="mt-3 flex justify-center">
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "upload", icon: <Upload className="size-4" />, label: "导入素材", onClick: onUpload },
{ key: "text", icon: <Type className="size-4" />, label: "新建文本", onClick: onAddText },
{ key: "storyboard", icon: <Clapperboard className="size-4" />, label: "新建空白分镜", onClick: onAddScript },
],
}}
>
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, ...focusStyle }}>
<MoreHorizontal className="size-4" />其他起点<ChevronDown className="size-3" />
</button>
</Dropdown>
</div>
</div>
</div>
);
}
export function CanvasFreeformEmptyState({ onUpload, onAddText }: { onUpload: () => void; onAddText: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center px-4 pb-20 pt-24">
<div className="pointer-events-auto w-full max-w-[440px] rounded-lg border p-4 shadow-sm backdrop-blur" data-canvas-no-zoom style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="text-center"><h2 className="text-base font-semibold">从空白画布开始</h2><p className="mt-1 text-xs" style={{ color: theme.node.muted }}>添加文本或导入已有素材。</p></div>
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onAddText} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Type className="size-4" />新建文本</button>
<button type="button" onClick={onUpload} className="inline-flex h-10 items-center justify-center gap-2 rounded-md border text-sm font-medium" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}><Upload className="size-4" />导入素材</button>
</div>
</div>
</div>
);
}
function PathCard({ icon, title, description, action, accent, theme, focusStyle, onClick }: {
icon: ReactNode;
title: string;
description: string;
action: string;
accent: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
focusStyle: CSSProperties;
onClick: () => void;
}) {
return (
<section className="flex min-h-[176px] flex-col rounded-lg border p-4 shadow-sm backdrop-blur" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<span className="grid size-9 place-items-center rounded-md" style={{ background: `${accent}16`, color: accent }}>{icon}</span>
<div className="mt-3 text-base font-semibold">{title}</div>
<p className="mt-1 min-h-10 text-sm leading-5" style={{ color: theme.node.muted }}>{description}</p>
<button type="button" className="mt-auto inline-flex h-9 w-full items-center justify-between rounded-md border px-3 text-sm font-semibold outline-none transition hover:brightness-105 focus-visible:ring-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text, ...focusStyle }} onClick={onClick}>
<span>{action}</span><ArrowRight className="size-4" />
</button>
</section>
);
}
export function CanvasShortDramaGuide({ progress, collapsed, onToggle, onSkip, onStepClick }: {
progress: CanvasShortDramaProgress;
collapsed: boolean;
onToggle: () => void;
onSkip: () => void;
onStepClick: (stepId: CanvasShortDramaStepId) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
if (!progress.active || collapsed) return null;
return (
<div data-canvas-no-zoom className="absolute left-1/2 top-[var(--canvas-topbar-offset)] z-[var(--z-toolbar)] flex max-w-[calc(100%_-_24px)] -translate-x-1/2 items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
<div className="hide-scrollbar flex min-w-0 flex-1 items-center overflow-x-auto">
<div className="flex shrink-0 items-center px-[var(--space-1)]">
{progress.steps.map((step, index) => (
<Fragment key={step.id}>
{index ? (
<span
aria-hidden
className="shrink-0 rounded-full transition-colors duration-150 motion-reduce:transition-none"
style={{ width: "var(--flow-step-track-width)", height: "var(--flow-step-track-height)", background: step.status === "pending" ? theme.node.stroke : theme.accent.primary }}
/>
) : null}
<button
type="button"
aria-current={step.status === "current" ? "step" : undefined}
className="flex h-8 shrink-0 items-center gap-[var(--flow-step-gap)] rounded-md px-[var(--space-1-half)] outline-none transition-colors motion-reduce:transition-none hover:bg-black/5 focus-visible:ring-1 focus-visible:ring-inset dark:hover:bg-white/10"
style={{ "--tw-ring-color": theme.accent.primary } as CSSProperties}
onClick={() => onStepClick(step.id)}
>
<span
className="grid shrink-0 place-items-center rounded-full font-semibold transition-all duration-150 motion-reduce:transition-none"
style={{
width: step.status === "current" ? "var(--flow-step-node-current)" : "var(--flow-step-node)",
height: step.status === "current" ? "var(--flow-step-node-current)" : "var(--flow-step-node)",
background: step.status === "pending" ? "transparent" : theme.accent.primary,
border: step.status === "pending" ? "var(--stroke-2) solid var(--cn-stroke)" : "none",
color: step.status === "pending" ? theme.node.muted : theme.accent.onPrimary,
boxShadow: step.status === "current" ? "var(--flow-step-current-glow)" : undefined,
fontSize: step.status === "current" ? "var(--fs-body)" : "var(--fs-caption)",
}}
>
{step.status === "completed" ? <Check className="size-3.5" /> : index + 1}
</span>
<span
className="whitespace-nowrap text-[var(--fs-caption)] font-semibold transition-colors duration-150 motion-reduce:transition-none"
style={{ color: step.status === "current" ? theme.accent.primary : step.status === "completed" ? theme.node.text : theme.node.muted }}
>
{step.label}
</span>
</button>
</Fragment>
))}
</div>
</div>
<span className="mx-1 h-4 w-px shrink-0" style={{ background: theme.toolbar.border }} />
{!progress.completed ? <button type="button" className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[var(--fs-label)] outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, "--tw-ring-color": theme.accent.primary } as CSSProperties} onClick={onSkip}><X className="size-3" />跳过导引</button> : null}
<button type="button" className="grid size-8 shrink-0 place-items-center rounded-md outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.muted, "--tw-ring-color": theme.accent.primary } as CSSProperties} onClick={onToggle} aria-label="折叠短剧流程"><ChevronUp className="size-3.5" /></button>
</div>
);
}
export function CanvasStylePlaceholderNodeContent({ onChoose }: { onChoose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<div className="flex h-full w-full flex-col items-center justify-center px-6 text-center" style={{ color: theme.node.text }}>
<span className="grid size-10 place-items-center rounded-md" style={{ background: `${theme.accent.primary}16`, color: theme.accent.primary }}><Palette className="size-5" /></span>
<div className="mt-3 text-sm font-semibold">项目画风</div>
<div className="mt-1 text-xs" style={{ color: theme.node.muted }}>待选择</div>
<button type="button" className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-medium outline-none transition hover:brightness-105 focus-visible:ring-2" style={{ background: theme.toolbar.panel, borderColor: theme.node.stroke, "--tw-ring-color": theme.accent.primary } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onChoose(); }}><Sparkles className="size-3.5" />选择画风</button>
</div>
);
}
export function CanvasStoryInputNodeContent({ node, onEdit }: { node: CanvasNodeData; onEdit: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const content = (node.metadata?.content || "").replace(/\s+/g, " ").trim();
return (
<div className="flex h-full w-full flex-col overflow-hidden p-4" style={{ color: theme.node.text }}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2"><span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}><AlignLeft className="size-4" /></span><span className="truncate text-sm font-semibold">故事梗概</span></div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-hidden border-t pt-3 text-xs leading-6" style={{ borderColor: theme.node.stroke, color: content ? theme.node.muted : theme.node.placeholder }}>{content || "写下题材、角色、冲突和结局方向…"}</div>
<button type="button" className="mt-3 inline-flex h-8 w-fit items-center gap-1.5 rounded-md px-2 text-xs font-medium outline-none transition hover:bg-black/5 focus-visible:ring-2 dark:hover:bg-white/10" style={{ color: theme.node.text, "--tw-ring-color": theme.accent.primary } as CSSProperties} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onEdit(); }}><Pencil className="size-3.5" />编辑故事</button>
</div>
);
}