video-flow-b/components/ChatInputBox/H5TemplateDrawer.tsx
2025-09-24 21:13:49 +08:00

714 lines
30 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 { useEffect, useState, useRef } from "react";
import { Drawer, Tooltip, Upload, Image } from "antd";
import type { UploadRequestOption as RcCustomRequestOptions } from 'rc-upload/lib/interface';
import { UploadOutlined } from "@ant-design/icons";
import { Clapperboard, Sparkles, LayoutTemplate, ChevronDown, ChevronUp, CheckCircle2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
import { useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import GlobalLoad from "../common/GlobalLoad";
import { motion, AnimatePresence } from "framer-motion";
import { Dropdown } from "antd";
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
interface H5TemplateDrawerProps {
isMobile: boolean;
isTemplateCreating: boolean;
setIsTemplateCreating: (value: boolean) => void;
isRoleGenerating: { [key: string]: boolean };
setIsRoleGenerating: (
value:
| { [key: string]: boolean }
| ((prev: { [key: string]: boolean }) => { [key: string]: boolean })
) => void;
isItemGenerating: { [key: string]: boolean };
setIsItemGenerating: (
value:
| { [key: string]: boolean }
| ((prev: { [key: string]: boolean }) => { [key: string]: boolean })
) => void;
isOpen: boolean;
onClose: () => void;
/** 指定初始选中的模板ID用于从外部快速定位 */
initialTemplateId?: string;
configOptions: {
mode: "auto" | "manual";
resolution: "720p" | "1080p" | "4k";
language: string;
videoDuration: string;
};
}
export const H5TemplateDrawer = ({
isMobile,
isTemplateCreating,
setIsTemplateCreating,
isRoleGenerating,
setIsRoleGenerating,
isItemGenerating,
setIsItemGenerating,
isOpen,
onClose,
initialTemplateId,
configOptions,
}: H5TemplateDrawerProps) => {
const router = useRouter();
const {
templateStoryList,
selectedTemplate,
isLoading,
getTemplateStoryList,
actionStory,
setSelectedTemplate,
AvatarAndAnalyzeFeatures,
updateItemImage,
handleRoleFieldBlur,
handleItemFieldBlur,
clearData,
} = useTemplateStoryServiceHook();
const { uploadFile } = useUploadFile();
const [localLoading, setLocalLoading] = useState(0);
const [inputVisible, setInputVisible] = useState<{ [key: string]: boolean }>({});
const [isBottomExpanded, setIsBottomExpanded] = useState(true);
const [isDescExpanded, setIsDescExpanded] = useState(false);
// 自由输入框布局
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_PORTRAIT");
// 顶部列表所在的实际滚动容器(外层 top-section 才是滚动容器)
const topSectionRef = useRef<HTMLDivElement | null>(null);
// 自由输入框布局
useEffect(() => {
if (selectedTemplate?.storyRole && selectedTemplate.storyRole.length > 0 ||
selectedTemplate?.storyItem && selectedTemplate.storyItem.length > 0
) {
setFreeInputLayout('bottom');
} else {
setFreeInputLayout('top');
}
}, [selectedTemplate])
useEffect(() => {
if (isOpen) {
getTemplateStoryList();
}
}, [isOpen, getTemplateStoryList]);
// 当列表加载后,根据 initialTemplateId 自动选中
useEffect(() => {
if (!isOpen) return;
if (!initialTemplateId) return;
if (!templateStoryList || templateStoryList.length === 0) return;
const target = templateStoryList.find(t => t.id === initialTemplateId || t.template_id === initialTemplateId);
if (target) {
setSelectedTemplate(target);
}
}, [isOpen, initialTemplateId, templateStoryList, setSelectedTemplate]);
// 自动聚焦可编辑输入框
useEffect(() => {
if (!isOpen) return;
if (!selectedTemplate) return;
const timer = setTimeout(() => {
const topTextArea = document.querySelector('textarea[data-alt="h5-template-free-input-top"]') as HTMLTextAreaElement | null;
const bottomInput = document.querySelector('input[data-alt="h5-template-free-input-bottom"]') as HTMLInputElement | null;
if (freeInputLayout === 'top' && topTextArea) {
topTextArea.focus();
} else if (freeInputLayout === 'bottom' && bottomInput) {
bottomInput.focus();
}
}, 50);
return () => clearTimeout(timer);
}, [isOpen, selectedTemplate, freeInputLayout]);
// 当存在默认选中模板时,将其滚动到顶部(以外层 top-section 为滚动容器)
useEffect(() => {
if (!isOpen) return;
if (!selectedTemplate) return;
const container = topSectionRef.current;
if (!container) return;
// 延迟一帧确保子节点渲染
const tid = setTimeout(() => {
const targetId = (selectedTemplate as any).id || (selectedTemplate as any).template_id;
const el = container.querySelector(`[data-template-id="${targetId}"]`) as HTMLElement | null;
if (el) {
// 计算相对容器的 offsetTop
const containerTop = container.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
const offset = elTop - containerTop + container.scrollTop;
const adjust = 16; // 向下偏移一些,让目标项不贴顶
const targetTop = Math.max(0, offset - adjust);
container.scrollTo({ top: targetTop, behavior: 'smooth' });
}
}, 0);
return () => clearTimeout(tid);
}, [isOpen, selectedTemplate]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
if (!target.closest(".ant-tooltip") && !target.closest('[data-alt*="field-ai-button"]')) {
setInputVisible({});
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleConfirm = async () => {
if (!selectedTemplate || isTemplateCreating) return;
setIsTemplateCreating(true);
let timer: ReturnType<typeof setInterval> | null = null;
try {
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) return;
timer = setInterval(() => {
setLocalLoading((prev) => (prev >= 95 ? 95 : prev + 0.1));
}, 100);
setLocalLoading(1);
const projectId = await actionStory(
String(User.id),
configOptions.mode,
configOptions.resolution,
configOptions.language,
aspectUI as AspectRatioValue
);
if (projectId) {
router.push(`/movies/work-flow?episodeId=${projectId}`);
onClose();
setSelectedTemplate(null);
}
} catch (error) {
console.log("Failed to create story action:", error);
window.msg.error(error instanceof Error ? error.message : "Failed to create story action");
setIsTemplateCreating(false);
setLocalLoading(0);
} finally {
setLocalLoading(0);
if (timer) clearInterval(timer);
}
};
const renderTopTemplateList = () => {
return (
<div
data-alt="top-template-list"
className="w-full p-3 border-b border-white/10 overflow-y-auto"
>
<div className="flex flex-col gap-2">
{templateStoryList.map((template, index) => {
const isSelected = selectedTemplate?.id === template.id;
return (
<button
key={template.id}
data-alt={`template-row-${index}`}
data-template-id={(template as any).id || (template as any).template_id}
onClick={() => {
if (!isBottomExpanded) setIsBottomExpanded(true);
setSelectedTemplate(template);
}}
className={`${isSelected ? "ring-1 ring-blue-500/60 bg-white/[0.06]" : "bg-white/0"} w-full flex items-center gap-3 rounded-xl border border-white/10 hover:border-white/20 hover:bg-white/[0.04] transition-colors p-2`}
>
<div data-alt="template-cover" className="w-16 h-20 rounded-lg overflow-hidden border border-white/10 flex-shrink-0">
<Image
src={template.image_url[0]}
alt={template.name}
className="w-full h-full object-cover"
preview={{ mask: null, maskClassName: "hidden" }}
/>
</div>
<div data-alt="template-info" className="flex-1 min-w-0 text-left">
<div className="flex items-start gap-2">
<h4 className="text-sm font-semibold text-white truncate">
{template.name}
</h4>
</div>
<span className="mt-1 text-xs text-white/60 leading-snug line-clamp-2">
{template.generateText}
</span>
</div>
<div className="ml-2 flex-shrink-0">
{isSelected ? (
<CheckCircle2 data-alt="template-selected-mark" className="w-5 h-5 text-blue-500" />
) : null}
</div>
</button>
);
})}
</div>
</div>
);
};
const renderRoles = () => {
if (!selectedTemplate?.storyRole || selectedTemplate.storyRole.length === 0) return null;
return (
<div data-alt="roles-section" className="pt-3 border-t border-white/10">
<h3 className="text-base font-semibold text-white mb-3">Character Configuration</h3>
<div className="grid grid-cols-3 gap-3">
{selectedTemplate.storyRole.map((role, index) => (
<div key={index} data-alt={`role-field-${index}`} className="flex flex-col items-center space-y-2">
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={role.role_description || ""}
onChange={(e) => {
const updatedTemplate: StoryTemplateEntity = {
...selectedTemplate!,
storyRole: selectedTemplate!.storyRole.map((r) =>
r.role_name === role.role_name
? { ...r, role_description: e.target.value }
: r
),
};
setSelectedTemplate(updatedTemplate);
}}
placeholder={role.user_tips}
className="w-[20rem] px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<ActionButton
isCreating={isRoleGenerating[role.role_name] || false}
handleCreateVideo={async () => {
if (role.role_description && role.role_description.trim()) {
setIsRoleGenerating((prev) => ({ ...prev, [role.role_name]: true }));
try {
await handleRoleFieldBlur(role.role_name, role.role_description.trim());
} finally {
setIsRoleGenerating((prev) => ({ ...prev, [role.role_name]: false }));
}
}
setInputVisible((prev) => ({ ...prev, [role.role_name]: false }));
}}
icon={<Sparkles className="w-4 h-4" />}
width="w-8"
height="h-8"
disabled={isRoleGenerating[role.role_name] || false}
/>
</div>
</div>
}
placement="top"
classNames={{ root: "max-w-none" }}
open={inputVisible[role.role_name]}
onOpenChange={(visible) =>
setInputVisible((prev) => ({ ...prev, [role.role_name]: visible }))
}
trigger="click"
styles={{ root: { zIndex: 1000 } }}
>
<div
data-alt={`role-thumbnail-${index}`}
className="w-20 h-20 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer"
>
<Image
src={role.photo_url || "/assets/empty_video.png"}
alt={role.role_name}
className="w-full h-full object-cover"
preview={{ mask: null, maskClassName: "hidden" }}
fallback="/assets/empty_video.png"
/>
</div>
</Tooltip>
<div className="absolute -top-[0.5rem] left-0 right-0 flex justify-center gap-2 opacity-100">
<button
data-alt={`role-ai-button-${index}`}
onClick={() =>
setInputVisible((prev) => ({ ...prev, [role.role_name]: !prev[role.role_name] }))
}
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
<Upload
name="roleImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) return false;
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) return false;
return true;
}}
customRequest={async ({ file, onSuccess, onError }: RcCustomRequestOptions) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(fileObj, () => {});
await AvatarAndAnalyzeFeatures(uploadedUrl, role.role_name);
onSuccess?.(uploadedUrl as any);
} catch (error) {
onError?.(error as Error);
}
}}
>
<Tooltip title="upload your image" placement="top">
<button
data-alt={`role-upload-button-${index}`}
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
<div className="text-center mt-1">
<span className="text-white text-xs font-medium">{role.role_name}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};
const renderItems = () => {
if (!selectedTemplate?.storyItem || selectedTemplate.storyItem.length === 0) return null;
return (
<div data-alt="items-section" className="pt-3 border-t border-white/10">
<h3 className="text-base font-semibold text-white mb-3">props Configuration</h3>
<div className="grid grid-cols-3 gap-3">
{selectedTemplate.storyItem.map((item, index) => (
<div key={index} data-alt={`item-field-${index}`} className="flex flex-col items-center space-y-2">
<div className="relative group">
<Tooltip
title={
<div className="relative">
<input
type="text"
value={item.item_description || ""}
onChange={(e) => {
const updatedTemplate: StoryTemplateEntity = {
...selectedTemplate!,
storyItem: selectedTemplate!.storyItem.map((i) =>
i.item_name === item.item_name
? { ...i, item_description: e.target.value }
: i
),
};
setSelectedTemplate(updatedTemplate);
}}
placeholder="Enter description for AI image generation..."
className="w-[20rem] px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<ActionButton
isCreating={isItemGenerating[item.item_name] || false}
handleCreateVideo={async () => {
if (item.item_description && item.item_description.trim()) {
setIsItemGenerating((prev) => ({ ...prev, [item.item_name]: true }));
try {
await handleItemFieldBlur(item.item_name, item.item_description.trim());
} finally {
setIsItemGenerating((prev) => ({ ...prev, [item.item_name]: false }));
}
}
setInputVisible((prev) => ({ ...prev, [item.item_name]: false }));
}}
icon={<Sparkles className="w-4 h-4" />}
width="w-8"
height="h-8"
disabled={isItemGenerating[item.item_name] || false}
/>
</div>
</div>
}
placement="top"
classNames={{ root: "max-w-none" }}
open={inputVisible[item.item_name]}
onOpenChange={(visible) =>
setInputVisible((prev) => ({ ...prev, [item.item_name]: visible }))
}
trigger="click"
styles={{ root: { zIndex: 1000 } }}
>
<div
data-alt={`item-thumbnail-${index}`}
className="w-20 h-20 rounded-xl overflow-hidden border border-white/10 bg-white/0 flex items-center justify-center cursor-pointer"
>
<Image
src={item.photo_url || "/assets/empty_video.png"}
alt={item.item_name}
className="w-full h-full object-cover"
preview={{ mask: null, maskClassName: "hidden" }}
fallback="/assets/empty_video.png"
/>
</div>
</Tooltip>
<div className="absolute -top-[0.5rem] left-0 right-0 flex justify-center gap-2 opacity-100">
<button
data-alt={`item-ai-button-${index}`}
onClick={() =>
setInputVisible((prev) => ({ ...prev, [item.item_name]: !prev[item.item_name] }))
}
className="w-6 h-6 bg-purple-500 hover:bg-purple-600 text-white rounded-full flex items-center justify-center"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
<Upload
name="itemImage"
showUploadList={false}
beforeUpload={(file) => {
const isImage = file.type.startsWith("image/");
if (!isImage) return false;
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) return false;
return true;
}}
customRequest={async ({ file, onSuccess, onError }: RcCustomRequestOptions) => {
try {
const fileObj = file as File;
const uploadedUrl = await uploadFile(fileObj, () => {});
updateItemImage(item.item_name, uploadedUrl);
onSuccess?.(uploadedUrl as any);
} catch (error) {
onError?.(error as Error);
}
}}
>
<Tooltip title="upload your image" placement="top">
<button
data-alt={`item-upload-button-${index}`}
className="w-6 h-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full flex items-center justify-center"
>
<UploadOutlined className="w-3.5 h-3.5" />
</button>
</Tooltip>
</Upload>
</div>
<div className="text-center mt-1">
<span className="text-white text-xs font-medium">{item.item_name}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
};
const renderBottomDetail = () => {
if (!selectedTemplate) {
return (
<div data-alt="no-template" className="flex items-center justify-center py-12">
<div className="text-center text-white/60">
<LayoutTemplate className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="text-base">No templates available</p>
<p className="text-xs">Please try again later</p>
</div>
</div>
);
}
return (
<div data-alt="selected-template-detail" className="w-full p-3 space-y-3">
<div className="flex items-start gap-3">
<div className="w-24 h-32 rounded-lg overflow-hidden border border-white/10">
<Image
src={selectedTemplate.image_url[0]}
alt={selectedTemplate.name}
className="w-full h-full object-cover"
preview={{ mask: null, maskClassName: "hidden" }}
/>
</div>
<div className="flex-1 min-w-0">
<h2 data-alt="template-title" className="text-lg font-bold text-white mb-1 truncate">
{selectedTemplate.name}
</h2>
<div data-alt="desc-container" className="relative">
<p
data-alt="template-description"
className={`text-gray-300 text-xs leading-relaxed ${isDescExpanded ? "" : "line-clamp-4"}`}
>
{selectedTemplate.generateText}
</p>
{!isDescExpanded && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-black/60 to-transparent" />
)}
</div>
<div className="mt-1">
<button
data-alt="desc-toggle"
onClick={() => setIsDescExpanded((v) => !v)}
className="inline-flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300"
>
<span>{isDescExpanded ? "Collapse" : "Expand"}</span>
{isDescExpanded ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</button>
</div>
</div>
</div>
{renderRoles()}
{renderItems()}
{/** 自由输入文字 */}
{freeInputLayout === 'top' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
<div className="py-2 flex-1 flex flex-col">
<h3
data-alt="items-section-title"
className="text-base font-semibold text-white mb-3"
>
input Configuration
</h3>
<textarea
data-alt="h5-template-free-input-top"
value={selectedTemplate?.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate?.freeInput[0].user_tips || ""}
className="w-full flex-1 px-3 py-2 pr-16 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
// 更新自由输入文字字段
const updatedTemplate = {
...selectedTemplate!,
freeInput: selectedTemplate!.freeInput.map((item) => ({
...item,
free_input_text: e.target.value
})),
};
setSelectedTemplate(updatedTemplate as StoryTemplateEntity);
}}
/>
</div>
)}
<div className="w-full flex items-center justify-end gap-2">
{freeInputLayout === 'bottom' && selectedTemplate?.freeInput && selectedTemplate.freeInput.length > 0 && (
<div data-alt="free-input" className="flex-1">
<input
type="text"
data-alt="h5-template-free-input-bottom"
value={selectedTemplate.freeInput[0].free_input_text || ""}
placeholder={selectedTemplate.freeInput[0].user_tips}
className="w-full px-3 py-2 pr-12 bg-white/0 border border-white/10 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-all duration-200 text-sm"
onChange={(e) => {
const updatedTemplate = {
...selectedTemplate!,
freeInput: selectedTemplate!.freeInput.map((item) => ({
...item,
free_input_text: e.target.value,
})),
} as StoryTemplateEntity;
setSelectedTemplate(updatedTemplate);
}}
/>
</div>
)}
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={aspectUI}
onChange={(v: AspectRatioValue) => setAspectUI(v)}
placement="top"
/>
<ActionButton
isCreating={isTemplateCreating || localLoading > 0}
handleCreateVideo={handleConfirm}
icon={<Clapperboard className="w-4 h-4" />}
disabled={isTemplateCreating || localLoading > 0}
width="w-10"
height="h-10"
/>
</div>
</div>
);
};
return (
<Drawer
open={isOpen}
placement="left"
width={isMobile ? "80%" : "40%"}
maskClosable={true}
closable={false}
onClose={() => {
clearData();
onClose();
}}
className="h5-template-drawer [&_.ant-drawer-body]:!p-0 bg-white/[0.02]"
styles={{
body: {
height: `calc(100vh - 3rem)`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
},
}}
>
<GlobalLoad show={isLoading} progress={localLoading}>
<div data-alt="drawer-content" className="flex flex-col h-[100svh]">
<div data-alt="drawer-header" className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h2 className="text-medium font-bold text-white">Template Story</h2>
</div>
<div data-alt="drawer-body" className="flex-1 overflow-y-auto">
<div data-alt="top-section" className="h-full overflow-y-auto" ref={topSectionRef}>
{renderTopTemplateList()}
</div>
<motion.div
data-alt="bottom-section"
className={`absolute left-0 right-0 bottom-0 bg-white/[0.04] border-t border-white/10 rounded-t-xl shadow-[0_-8px_30px_rgba(0,0,0,0.35)] backdrop-blur-md z-30 overflow-visible`}
style={{ maxHeight: "90vh" }}
animate={{ height: isBottomExpanded ? "auto" : 48 }}
transition={{ type: "spring", stiffness: 220, damping: 26 }}
>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 z-50 backdrop-blur-md">
<motion.button
data-alt="toggle-bottom-section"
onClick={() => setIsBottomExpanded((v) => !v)}
className="w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 border border-white/20 text-white flex items-center justify-center shadow-xl"
whileTap={{ scale: 0.96 }}
>
<motion.div animate={{ rotate: isBottomExpanded ? 0 : 180 }} transition={{ duration: 0.2 }}>
<ChevronDown className="w-5 h-5" />
</motion.div>
</motion.button>
</div>
<AnimatePresence initial={false}>
{isBottomExpanded && (
<motion.div
key="bottom-content"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 16 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="p-0 overflow-y-auto"
style={{ maxHeight: "90vh" }}
>
{renderBottomDetail()}
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</GlobalLoad>
</Drawer>
);
};
export default H5TemplateDrawer;