2025-09-28 21:37:17 +08:00

692 lines
28 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 { useState, useEffect, useRef, useCallback } from "react";
import {
ChevronDown,
ChevronUp,
Video,
Loader2,
Lightbulb,
Package,
Crown,
Clapperboard,
Globe,
Clock,
Trash2,
LayoutTemplate,
ImagePlay,
Sparkles,
Settings,
MoreHorizontal,
} from "lucide-react";
import {
Dropdown,
Modal,
Tooltip,
Upload,
Popconfirm,
Image,
Popover,
Switch,
} from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { StoryTemplateEntity } from "@/app/service/domain/Entities";
import { useImageStoryServiceHook } from "@/app/service/Interaction/ImageStoryService";
import TemplateCard from "./templateCard";
import { AudioRecorder } from "./AudioRecorder";
import { useRouter } from "next/navigation";
import { createMovieProjectV1 } from "@/api/video_flow";
import {
MovieProjectService,
MovieProjectMode,
} from "@/app/service/Interaction/MovieProjectService";
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import { HighlightEditor } from "../common/HighlightEditor";
import GlobalLoad from "../common/GlobalLoad";
import { useDeviceType } from '@/hooks/useDeviceType';
import { PcTemplateModal } from "./PcTemplateModal";
import { H5TemplateDrawer } from "./H5TemplateDrawer";
import { PcPhotoStoryModal } from "./PcPhotoStoryModal";
import { H5PhotoStoryDrawer } from "./H5PhotoStoryDrawer";
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
const LauguageOptions = [
{ value: "english", label: "English", isVip: false, code:'EN' },
{ value: "chinese", label: "Chinese", isVip: false, code:'ZH' },
{ value: "japanese", label: "Japanese", isVip: false, code:'JA' },
{ value: "spanish", label: "Spanish", isVip: false, code:'ES' },
{ value: "portuguese", label: "Portuguese", isVip: false, code:'PT' },
{ value: "hindi", label: "Hindi", isVip: false, code:'HI' },
{ value: "korean", label: "Korean", isVip: false, code:'KO' },
{ value: "arabic", label: "Arabic", isVip: false, code:'AR' },
{ value: "russian", label: "Russian", isVip: false, code:'RU' },
{ value: "thai", label: "Thai", isVip: false, code:'TH' },
{ value: "french", label: "French", isVip: false, code:'FR' },
{ value: "german", label: "German", isVip: false, code:'DE' },
{ value: "vietnamese", label: "Vietnamese", isVip: false, code:'VI' },
{ value: "indonesian", label: "Indonesian", isVip: false, code:'ID' }
]
const VideoDurationOptions = [
{ value: "8s", label: "8s" },
{ value: "1min", label: "1min" },
{ value: "2min", label: "2min" },
{ value: "unlimited", label: "auto" },
];
// aspect ratio options moved to reusable component
/**模板故事模式弹窗组件 */
/**
* 防抖函数
* @param {Function} func - 需要防抖的函数
* @param {number} wait - 等待时间(ms)
* @returns {Function} - 防抖后的函数
*/
const debounce = (func: Function, wait: number) => {
let timeout: ReturnType<typeof setTimeout>;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
/**
* 视频工具面板组件
* 提供脚本输入和视频克隆两种模式,支持展开/收起功能
*/
export function ChatInputBox({ noData }: { noData: boolean }) {
const { isMobile, isDesktop } = useDeviceType();
// 模板快捷入口拖动相关状态
const templateScrollRef = useRef<HTMLDivElement>(null);
const [isTemplateDragging, setIsTemplateDragging] = useState(false);
const [templateStartX, setTemplateStartX] = useState(0);
const [templateScrollLeft, setTemplateScrollLeft] = useState(0);
// 模板快捷入口拖动事件处理
const handleTemplateMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setIsTemplateDragging(true);
setTemplateStartX(e.pageX - templateScrollRef.current!.offsetLeft);
setTemplateScrollLeft(templateScrollRef.current!.scrollLeft);
}, []);
const handleTemplateMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isTemplateDragging) return;
e.preventDefault();
const x = e.pageX - templateScrollRef.current!.offsetLeft;
const walk = (x - templateStartX) * 2;
templateScrollRef.current!.scrollLeft = templateScrollLeft - walk;
}, [isTemplateDragging, templateStartX, templateScrollLeft]);
const handleTemplateMouseUp = useCallback(() => {
setIsTemplateDragging(false);
}, []);
// 控制面板展开/收起状态
const [isExpanded, setIsExpanded] = useState(false);
// 模板故事弹窗状态
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
// 模板快捷入口记录初始模板ID与是否自动聚焦
const [initialTemplateId, setInitialTemplateId] = useState<string | undefined>(undefined);
// 复用模板服务:获取模板列表
const {
templateStoryList,
isLoading: isTemplateLoading,
getTemplateStoryList,
} = useTemplateStoryServiceHook();
// 图片故事弹窗状态
const [isPhotoStoryModalOpen, setIsPhotoStoryModalOpen] = useState(false);
// 共享状态 - 需要在不同渲染函数间共享
const [script, setScript] = useState(""); // 用户输入的脚本内容
const router = useRouter();
const [loadingIdea, setLoadingIdea] = useState(false); // 获取创意建议时的加载状态
// 各种操作的加载状态
const [isCreating, setIsCreating] = useState(false); // 主视频创建按钮的加载状态
const [isTemplateCreating, setIsTemplateCreating] = useState(false); // 模板故事创建按钮的加载状态
const [isPhotoCreating, setIsPhotoCreating] = useState(false); // 图片故事创建按钮的加载状态
const [isRoleGenerating, setIsRoleGenerating] = useState<{[key: string]: boolean}>({}); // 角色AI生成按钮的加载状态
const [isItemGenerating, setIsItemGenerating] = useState<{[key: string]: boolean}>({}); // 道具AI生成按钮的加载状态
// 配置选项状态 - 整合所有配置项到一个对象
type ConfigOptions = {
mode: "auto" | "manual";
resolution: "720p" | "1080p" | "4k";
language: string;
videoDuration: string;
expansion_mode: boolean;
aspect_ratio: AspectRatioValue;
};
const [configOptions, setConfigOptions] = useState<ConfigOptions>({
mode: "auto",
resolution: "720p",
language: "english",
videoDuration: "unlimited",
expansion_mode: true,
aspect_ratio: isMobile ? "VIDEO_ASPECT_RATIO_PORTRAIT" : "VIDEO_ASPECT_RATIO_LANDSCAPE",
});
// 从 localStorage 初始化配置
useEffect(() => {
const savedConfig = localStorage.getItem('videoFlowConfig');
if (savedConfig) {
try {
const parsed = JSON.parse(savedConfig);
setConfigOptions({
mode: parsed.mode || "auto",
resolution: parsed.resolution || "720p",
language: parsed.language || "english",
videoDuration: parsed.videoDuration || "unlimited",
expansion_mode: typeof parsed.expansion_mode === 'boolean' ? parsed.expansion_mode : true,
aspect_ratio: parsed.aspect_ratio || (isMobile ? "VIDEO_ASPECT_RATIO_PORTRAIT" : "VIDEO_ASPECT_RATIO_LANDSCAPE"),
});
} catch (error) {
console.warn('解析保存的配置失败,使用默认配置:', error);
}
}
}, [isMobile]);
// 跟踪用户是否手动修改过宽高比
const [hasUserChangedAspectRatio, setHasUserChangedAspectRatio] = useState(false);
// 监听设备类型变化,仅在用户未手动修改时动态调整默认宽高比
useEffect(() => {
if (!hasUserChangedAspectRatio) {
setConfigOptions(prev => ({
...prev,
aspect_ratio: isMobile ? "VIDEO_ASPECT_RATIO_PORTRAIT" : "VIDEO_ASPECT_RATIO_LANDSCAPE"
}));
}
}, [isMobile, hasUserChangedAspectRatio]);
const onConfigChange = <K extends keyof ConfigOptions>(key: K, value: ConfigOptions[K]) => {
setConfigOptions((prev: ConfigOptions) => ({
...prev,
[key]: value,
}));
// 如果用户手动修改了宽高比,标记为已修改
if (key === 'aspect_ratio') {
setHasUserChangedAspectRatio(true);
}
if (key === 'videoDuration') {
// 当选择 8s 时,强制关闭剧本扩展并禁用开关
if (value === '8s') {
setConfigOptions((prev: ConfigOptions) => ({
...prev,
expansion_mode: false,
}));
} else {
setConfigOptions((prev: ConfigOptions) => ({
...prev,
expansion_mode: true,
}));
}
}
};
useEffect(() => {
if (!templateStoryList || templateStoryList.length === 0) {
getTemplateStoryList();
}
}, []);
// H5 文本输入框聚焦动画控制
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [isInputFocused, setIsInputFocused] = useState(false);
const [persistedMobileMaxHeight, setPersistedMobileMaxHeight] = useState<number | null>(null);
const handleCreateVideo = async () => {
if (isCreating) return; // 如果正在创建中,直接返回
if (!script) {
console.warn("请输入剧本内容");
return;
}
setIsCreating(true); // 设置创建状态为 true按钮会显示 loading 状态
try {
const User = JSON.parse(localStorage.getItem("currentUser") || "{}");
if (!User.id) {
console.error("用户未登录");
return;
}
// 创建剧集数据
let episodeData: any = {
user_id: String(User.id),
script: script,
mode: configOptions.mode,
resolution: configOptions.resolution,
language: configOptions.language,
video_duration: configOptions.videoDuration,
expansion_mode: configOptions.expansion_mode,
aspect_ratio: configOptions.aspect_ratio,
};
// 调用创建剧集API
const result = await MovieProjectService.createProject(
MovieProjectMode.NORMAL,
episodeData
);
const episodeId = result.project_id;
router.push(`/movies/work-flow?episodeId=${episodeId}`);
} catch (error) {
console.error("创建剧集失败:", error);
} finally {
setIsCreating(false); // 无论成功还是失败,都重置创建状态
}
};
return (
<div
className="video-tool-component relative max-w-[1080px] w-full left-[50%] translate-x-[-50%]"
style={
noData
? {
top: "50%",
}
: {}
}
>
{/* 视频故事板工具面板 - 毛玻璃效果背景 */}
<div className="video-storyboard-tools rounded-[20px] bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
{/* 展开/收起控制区域 */}
{!noData && (
<>
{isExpanded ? (
// 展开状态:显示收起按钮和提示
<div
className="absolute top-0 bottom-0 left-0 right-0 z-[1] flex flex-col items-center justify-center rounded-[20px] bg-[#191B1E] bg-opacity-[0.3] backdrop-blur-[1.5px] cursor-pointer"
onClick={() => setIsExpanded(false)}
>
<ChevronUp className="w-4 h-4 text-white/80" />
<span className="text-sm text-white/80">
Click to act
</span>
</div>
) : (
// 收起状态:显示展开按钮
<div
className="absolute top-[-8px] left-[50%] z-[2] flex items-center justify-center w-[46px] h-4 rounded-[15px] bg-[#fff3] translate-x-[-50%] cursor-pointer hover:bg-[#ffffff1a]"
onClick={() => setIsExpanded(true)}
>
<ChevronDown className="w-4 h-4" />
</div>
)}
</>
)}
{/* 主要内容区域 - 简化层级,垂直居中 */}
<div
data-alt="+ ---------"
className={`flex items-center justify-center p-1 transition-all duration-300 relative ${
isExpanded ? "h-[2rem]" : "h-auto"
}`}
>
{/* 输入框和Action按钮 - 只在展开状态显示 */}
{!isExpanded && (
<div data-alt="chat-input-box" className="flex flex-col w-full">
{/* 第一行:输入框 */}
<div className="video-prompt-editor mb-3 relative flex flex-col gap-3 flex-1 pr-10">
{/* 文本输入框 - 改为textarea */}
<textarea
data-alt="story-input"
ref={textareaRef}
value={script}
onChange={(e) => setScript(e.target.value)}
placeholder="Describe the story you want to make..."
className={`w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none overflow-y-auto transition-all duration-300 ease-in-out ${isMobile ? '' : 'max-h-[120px]'}`}
style={{
minHeight: noData ? "128px" : (isMobile ? (isInputFocused ? "96px" : "48px") : "unset"),
maxHeight: isMobile ? (isInputFocused ? "200px" : (persistedMobileMaxHeight ? `${persistedMobileMaxHeight}px` : "120px")) : undefined,
}}
rows={1}
onFocus={() => {
if (!isMobile) return;
setIsInputFocused(true);
const el = textareaRef.current;
if (el) {
const limit = 200;
// 以当前高度为起点,过渡到目标高度
const start = `${el.getBoundingClientRect().height}px`;
const end = `${Math.min(Math.max(el.scrollHeight, 96), limit)}px`;
el.style.height = start;
void el.offsetHeight;
el.style.height = end;
}
}}
onBlur={() => {
if (!isMobile) return;
setIsInputFocused(false);
const el = textareaRef.current;
if (el) {
const baseLimit = 120;
const contentHeight = el.scrollHeight;
const currentHeight = el.getBoundingClientRect().height;
// 若内容高度已超过基础高度,则保持较大高度,不回落
if (contentHeight > baseLimit || currentHeight > baseLimit) {
setPersistedMobileMaxHeight(Math.min(contentHeight, 200));
el.style.height = `${Math.min(contentHeight, 200)}px`;
} else {
const start = `${currentHeight}px`;
const end = `${Math.min(contentHeight, baseLimit)}px`;
el.style.height = start;
void el.offsetHeight;
el.style.height = end;
setPersistedMobileMaxHeight(null);
}
}
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
const limit = isMobile && isInputFocused ? 200 : (persistedMobileMaxHeight ?? 120);
target.style.height = "auto";
target.style.height = Math.min(target.scrollHeight, limit) + "px";
}}
onTransitionEnd={() => {
// 过渡结束后清理高度,避免下次动画受限
if (!isMobile) return;
if (!isInputFocused) {
const el = textareaRef.current;
if (el) {
// 若已记录持久高度则保持,不清理;否则清理
if (persistedMobileMaxHeight) {
el.style.height = `${persistedMobileMaxHeight}px`;
} else {
el.style.height = "";
}
}
}
}}
/>
</div>
{/* 第二行功能按钮和Action按钮 - 同一行 */}
<div className="flex items-center justify-between">
{/* 左侧功能按钮区域 */}
<div className="flex items-center gap-1 flex-wrap sm:flex-nowrap">
{/* 获取创意按钮
<Tooltip
title="Get creative ideas for your story"
placement="top"
>
<button
data-alt="get-idea-button"
className="flex items-center gap-1.5 px-3 py-2 text-white/[0.70] hover:text-white transition-colors"
onClick={() => handleGetIdea()}
>
{loadingIdea ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Lightbulb className="w-4 h-4" />
)}
</button>
</Tooltip> */}
{/* 分隔线 */}
{/* <div className="w-px h-4 bg-white/[0.20]"></div> */}
{/* 模板故事按钮 */}
<Tooltip title="Choose from movie templates" placement="top" trigger={isDesktop ? "hover" : "contextMenu"}>
<button
data-alt="template-story-button"
className="flex items-center gap-1.5 px-2 py-2 text-white/[0.70] hover:text-white transition-colors"
onClick={() => {
setInitialTemplateId(undefined);
setIsTemplateModalOpen(true);
}}
>
<LayoutTemplate className="w-4 h-4" />
</button>
</Tooltip>
{/* 分隔线 */}
<div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div>
{/* 图片故事按钮 */}
<Tooltip title="Create movie from image" placement="top" trigger={isDesktop ? "hover" : "contextMenu"}>
<button
data-alt="photo-story-button"
className="flex items-center gap-1.5 px-2 py-2 text-white/[0.70] hover:text-white transition-colors"
onClick={() => setIsPhotoStoryModalOpen(true)}
>
<ImagePlay className="w-4 h-4" />
</button>
</Tooltip>
{isDesktop ? (
<PcPhotoStoryModal
isOpen={isPhotoStoryModalOpen}
onClose={() => setIsPhotoStoryModalOpen(false)}
configOptions={configOptions}
isCreating={isCreating}
setIsCreating={setIsCreating}
isPhotoCreating={isPhotoCreating}
setIsPhotoCreating={setIsPhotoCreating}
/>
) : (
<H5PhotoStoryDrawer
isMobile={isMobile}
isOpen={isPhotoStoryModalOpen}
onClose={() => setIsPhotoStoryModalOpen(false)}
configOptions={configOptions}
isCreating={isCreating}
setIsCreating={setIsCreating}
isPhotoCreating={isPhotoCreating}
setIsPhotoCreating={setIsPhotoCreating}
/>
)}
{/* 分隔线 */}
<div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div>
{/* 语言配置 */}
<Dropdown
overlayClassName="language-dropdown"
menu={{
items: LauguageOptions.map((option) => ({
key: option.value,
label: (
<div className={`flex items-center justify-between px-2 py-2 ${
option.value === configOptions.language
? "bg-white/[0.12] rounded-md"
: ""
}`}>
<span className="text-sm text-white">{option.label}</span>
{option.isVip && (
<Crown className="w-4 h-4 text-yellow-500" />
)}
</div>
),
})),
onClick: ({ key }: { key: string }) => onConfigChange('language', key),
}}
trigger={["click"]}
placement="top"
>
<button
data-alt={`config-language`}
className={`flex items-center gap-1 text-white/80 transition-all duration-200 ${isMobile ? 'px-1' : 'px-2'} py-2`}
>
<Globe className={"w-4 h-4"} />
<span className="text-sm">{LauguageOptions.find((option) => option.value === configOptions.language)?.code}</span>
</button>
</Dropdown>
{/* 分隔线 */}
<div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div>
{/* 剧本扩展开关 */}
<Tooltip title="Enable script expansion" placement="top" trigger={isDesktop ? "hover" : "click"}>
<div data-alt="config-expansion-mode" className="flex items-center gap-1 px-1">
{/* 仅在未选中时显示背景色,避免覆盖选中态 */}
<div className={`${configOptions.expansion_mode ? 'bg-transparent' : 'bg-white/40'} rounded-full transition-colors flex`}>
<Switch
size="small"
checked={configOptions.expansion_mode}
disabled={configOptions.videoDuration === '8s'}
onChange={(checked: boolean) => onConfigChange('expansion_mode', checked)}
/>
</div>
<span className={`text-xs text-white hidden sm:inline`}>
{configOptions.expansion_mode ? 'On' : 'Off'}
</span>
</div>
</Tooltip>
{/* 分隔线 */}
<div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div>
{/* 时长选择 */}
<Dropdown
overlayClassName="duration-dropdown"
menu={{
items: VideoDurationOptions.map((option) => ({
key: option.value,
label: (
<div className={`flex items-center justify-between px-2 py-2 ${
option.value === configOptions.videoDuration
? "bg-white/[0.12] rounded-md"
: ""
}`}>
<span className="text-sm text-white">{option.label}</span>
</div>
),
})),
onClick: ({ key }: { key: string }) => onConfigChange('videoDuration', key as string),
}}
trigger={["click"]}
placement="top"
>
<button
data-alt="config-video-duration"
className={`flex items-center gap-1 text-white/80 transition-all duration-200 ${isMobile ? 'px-1' : 'px-2'} py-2`}
>
<Clock className={"w-4 h-4"} />
<span className="text-sm">{configOptions.videoDuration === 'unlimited' ? 'auto' : (isMobile ? configOptions.videoDuration.replace('min', 'm') : configOptions.videoDuration)}</span>
<ChevronUp className="w-3 h-3 text-white/60" />
</button>
</Dropdown>
{/* 分隔线(移动端隐藏,避免拥挤) */}
<div className="hidden sm:block w-px h-4 bg-white/[0.20]"></div>
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={configOptions.aspect_ratio}
onChange={(v) => onConfigChange('aspect_ratio', v)}
placement="top"
className={`${isMobile ? '!px-1' : ''}`}
/>
</div>
{/* 右侧Action按钮 */}
<ActionButton
isCreating={isCreating}
handleCreateVideo={handleCreateVideo}
icon={<Clapperboard className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} />}
className="mr-1 mb-1"
width={isMobile ? "w-10" : "w-12"}
height={isMobile ? "h-10" : "h-12"}
/>
</div>
{/* 第三行:模板快捷入口水平滚动 */}
<div data-alt="template-quick-entries" className="relative pl-2">
<div
ref={templateScrollRef}
className={`flex items-center gap-2 overflow-x-auto scrollbar-hide pr-6 py-1 cursor-grab select-none ${isTemplateDragging ? 'cursor-grabbing' : ''}`}
onMouseDown={handleTemplateMouseDown}
onMouseMove={handleTemplateMouseMove}
onMouseUp={handleTemplateMouseUp}
onMouseLeave={handleTemplateMouseUp}
>
{isTemplateLoading && (!templateStoryList || templateStoryList.length === 0) ? (
// 骨架屏:若正在加载且没有数据
Array.from({ length: 6 }).map((_, idx) => (
<div
key={`skeleton-${idx}`}
data-alt={`template-chip-skeleton-${idx}`}
className="flex-shrink-0 w-20 h-7 rounded-full bg-white/10 animate-pulse"
/>
))
) : (
(templateStoryList || []).map((tpl) => (
<button
key={tpl.id}
data-alt={`template-chip-${tpl.id}`}
className="flex-shrink-0 px-3 py-1.5 select-none rounded-full bg-white/10 hover:bg-white/20 text-white/80 hover:text-white text-xs transition-colors"
onClick={() => {
// id 映射:优先使用模板的 id若需要兼容 template_id则传两者之一
setInitialTemplateId(tpl.id || (tpl as any).template_id);
setIsTemplateModalOpen(true);
setTimeout(() => {
const textarea = document.querySelector('textarea');
if (textarea) (textarea as HTMLTextAreaElement).focus();
}, 0);
}}
>
{tpl.name}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* 模板故事弹窗 */}
{isDesktop ? (
<PcTemplateModal
configOptions={configOptions}
isOpen={isTemplateModalOpen}
onClose={() => setIsTemplateModalOpen(false)}
initialTemplateId={initialTemplateId}
isTemplateCreating={isTemplateCreating}
setIsTemplateCreating={setIsTemplateCreating}
isRoleGenerating={isRoleGenerating}
setIsRoleGenerating={setIsRoleGenerating}
isItemGenerating={isItemGenerating}
setIsItemGenerating={setIsItemGenerating}
/>
) : (
<H5TemplateDrawer
isMobile={isMobile}
configOptions={configOptions}
isOpen={isTemplateModalOpen}
onClose={() => setIsTemplateModalOpen(false)}
initialTemplateId={initialTemplateId}
isTemplateCreating={isTemplateCreating}
setIsTemplateCreating={setIsTemplateCreating}
isRoleGenerating={isRoleGenerating}
setIsRoleGenerating={setIsRoleGenerating}
isItemGenerating={isItemGenerating}
setIsItemGenerating={setIsItemGenerating}
/>
)}
</div>
);
}