更新 使用新模板流程

This commit is contained in:
moux1024 2025-10-21 01:02:59 +08:00
parent f2c4844e05
commit f44bee5009
10 changed files with 406 additions and 170 deletions

View File

@ -354,7 +354,7 @@ export interface CreateMovieProjectV4Request {
/** 视频时长 */
video_duration: string;
/** 是否是图生 */
is_image_to_video: boolean;
use_img2video: boolean;
/** pcode编码 */
pcode: string;
/** 角色简介数组 */

View File

@ -146,6 +146,7 @@ export interface StoryTemplateEntity {
template_id: string;
/** 故事模板视频URL */
show_url: string;
pcode: string;
/** 故事角色 */
storyRole: {
/** 角色名 */

View File

@ -5,6 +5,7 @@ import {
Clapperboard,
Sparkles,
LayoutTemplate,
ArrowRight
} from "lucide-react";
import {
Modal,
@ -22,25 +23,6 @@ import { useRouter } from "next/navigation";
import { useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import GlobalLoad from "../common/GlobalLoad";
import { AspectRatioSelector, AspectRatioValue } from "./AspectRatioSelector";
/**
*
* @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);
};
};
interface PcTemplateModalProps {
isTemplateCreating: boolean;
@ -108,7 +90,6 @@ export const PcTemplateModal = ({
// 自由输入框布局
const [freeInputLayout, setFreeInputLayout] = useState('bottom');
const router = useRouter();
const [aspectUI, setAspectUI] = useState<AspectRatioValue>("VIDEO_ASPECT_RATIO_LANDSCAPE");
const leftListRef = useRef<HTMLDivElement | null>(null);
// 组件挂载时获取模板列表
useEffect(() => {
@ -228,7 +209,6 @@ export const PcTemplateModal = ({
configOptions.mode,
configOptions.resolution,
configOptions.language,
aspectUI as AspectRatioValue
);
if (projectId) {
@ -755,16 +735,10 @@ export const PcTemplateModal = ({
/>
</div>
)}
{/* 横/竖屏选择 */}
<AspectRatioSelector
value={aspectUI}
onChange={setAspectUI}
placement="top"
/>
<ActionButton
isCreating={isTemplateCreating || localLoading > 0}
handleCreateVideo={handleConfirm}
icon={<Clapperboard className="w-5 h-5" />}
icon={<ArrowRight className="w-5 h-5" />}
disabled={isTemplateCreating || localLoading > 0}
/>
</div>

View File

@ -3,6 +3,7 @@
import type React from "react"
import { useEffect, useState } from "react"
import { X } from "lucide-react"
import TemplatePreviewModal from "@/components/common/TemplatePreviewModal"
import { PcTemplateModal } from "@/components/ChatInputBox/PcTemplateModal"
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService"
@ -21,7 +22,7 @@ const FamousTemplate: React.FC = () => {
const [isItemGenerating, setIsItemGenerating] = useState<{ [key: string]: boolean }>({})
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(null)
const [isPreviewReady, setIsPreviewReady] = useState(false)
const [activeTab, setActiveTab] = useState<"all" | "music" | "animation" | "thriller">("all")
const [activeTab, setActiveTab] = useState<"all" | "music" | "animation" | "fantasy">("all")
useEffect(() => {
void getTemplateStoryList()
@ -42,15 +43,15 @@ const FamousTemplate: React.FC = () => {
Hot Templates
</h2>
<div data-alt="template-tabs" className="flex items-center gap-2 ml-4">
{(["all", "music", "animation", "thriller"] as const).map((tab) => (
{(["all", "music", "animation", "fantasy"] as const).map((tab) => (
<button
key={tab}
type="button"
data-alt={`template-tab-${tab}`}
onClick={() => setActiveTab(tab)}
className={`px-3 py-1 italic rounded-none text-sm transition-colors border ${
className={`px-3 py-1 rounded-none text-sm transition-colors border ${
activeTab === tab
? "border-white/60 bg-white/60 text-slate-900"
? "border-white/60 bg-white/80 text-slate-900"
: "border-white/20 text-white/80 hover:border-white/40 hover:bg-white/10"
}`}
>
@ -119,78 +120,19 @@ const FamousTemplate: React.FC = () => {
const active = topTemplates.find((x) => (x.id || x.template_id) === activeTemplateId)
if (!active || !active.show_url) return null
return (
<div
data-alt="template-preview-modal"
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setActiveTemplateId(null)}
>
<div
data-alt="template-preview-modal-content"
className="relative w-[80vw] min-h-[40vw] rounded-lg overflow-hidden border border-white/30 bg-black shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<>
<video
src={active.show_url}
autoPlay
loop
muted
playsInline
onCanPlay={() => setIsPreviewReady(true)}
className={`w-full h-auto transition-opacity duration-200 ${isPreviewReady ? "opacity-100" : "opacity-0"}`}
/>
{!isPreviewReady && (
<div
data-alt="template-preview-loading"
className="absolute inset-0 flex items-center justify-center"
>
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white/80 animate-spin" />
</div>
)}
{/* template-preview-header */}
{isPreviewReady && (
<div
data-alt="template-preview-header"
className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/90 via-black/80 to-transparent px-6 py-8 text-center"
>
<div className="text-bold text-2xl text-white/90 line-clamp-2 text-left mb-4">
{active.name}
</div>
</div>
)}
{isPreviewReady && (
<div
data-alt="template-preview-footer"
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/80 to-transparent px-6 py-8 text-center"
>
<div className="text-base text-white/90 line-clamp-2 text-left mb-4">
{active.generateText || active.name}
</div>
<button
type="button"
className="items-center text-bold justify-center rounded-full border border-white/30 bg-white/10 px-6 py-2 text-sm text-xl text-white transition hover:border-white hover:bg-white hover:text-slate-900"
onClick={() => {
<TemplatePreviewModal
open={!!activeTemplateId}
videoUrl={active.show_url}
onClose={() => setActiveTemplateId(null)}
title={active.name}
description={active.generateText || active.name}
onPrimaryAction={() => {
setInitialTemplateId(active.id || active.template_id)
setIsModalOpen(true)
setActiveTemplateId(null)
}}
data-alt="template-preview-try-it"
>
Try it Free
</button>
</div>
)}
<button
type="button"
data-alt="template-preview-modal-close"
className="absolute top-3 right-3 inline-flex items-center justify-center rounded-full border border-white/30 bg-white/10 p-2 text-white hover:border-white hover:bg-white hover:text-slate-900"
onClick={() => setActiveTemplateId(null)}
>
<X className="h-4 w-4" />
</button>
</>
</div>
</div>
primaryLabel="Try it Free"
/>
)
})()}

View File

@ -2,10 +2,12 @@
import { useEffect, useState, useRef } from "react";
import { fetchSettingByCode } from "@/api/serversetting";
import { X, Eclipse } from "lucide-react";
import { X, ChevronUp, ChevronDown } from "lucide-react";
import { ChatInputBox } from "@/components/ChatInputBox/ChatInputBox";
import { VideoCreationForm } from '@/components/pages/create-video/CreateInput';
export const HOME_BANNER_CODE = "homeBanner";
const HOME_BANNER_COLLAPSE_KEY = "homeBannerCollapsedDate";
/** CTA config for banner */
export interface HomeBannerCTAConfig {
@ -38,9 +40,22 @@ export default function HomeBanner() {
const [error, setError] = useState<string | null>(null);
const [isFlying, setIsFlying] = useState<boolean>(false);
const autoCollapseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipAutoCollapseRef = useRef<boolean>(false);
/** Returns YYYY-MM-DD for user's local timezone */
const getLocalDateKey = () => {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
const handleDismiss = () => {
setIsFlying(true);
try {
localStorage.setItem(HOME_BANNER_COLLAPSE_KEY, getLocalDateKey());
} catch {}
};
const handleBannerClick = () => {
@ -49,12 +64,27 @@ export default function HomeBanner() {
}
};
// Auto collapse after mount (3s)
// Initialize from persisted state: keep collapsed for the rest of the day
useEffect(() => {
try {
const saved = localStorage.getItem(HOME_BANNER_COLLAPSE_KEY);
if (saved && saved === getLocalDateKey()) {
setIsFlying(true);
skipAutoCollapseRef.current = true;
}
} catch {}
}, []);
// Auto collapse after mount (2s) unless already collapsed today
useEffect(() => {
if (skipAutoCollapseRef.current) return;
if (autoCollapseTimerRef.current) return;
autoCollapseTimerRef.current = setTimeout(() => {
setIsFlying(true);
}, 3000);
try {
localStorage.setItem(HOME_BANNER_COLLAPSE_KEY, getLocalDateKey());
} catch {}
}, 2000);
return () => {
if (autoCollapseTimerRef.current) {
clearTimeout(autoCollapseTimerRef.current);
@ -130,9 +160,9 @@ export default function HomeBanner() {
{/* Banner overlay - stacked above */}
<section
data-alt="home-banner"
className={`absolute inset-0 z-10 isolate overflow-hidden rounded-3xl px-6 py-6 text-white border-2 border-transparent hover:border-custom-blue/50 transition-all duration-500 ease-in-out ${
className={`absolute inset-0 z-10 isolate overflow-hidden rounded-3xl px-6 py-6 text-white border-2 border-transparent hover:border-custom-blue/50 transition-all duration-400 ease-in-out ${
isFlying
? "cursor-pointer translate-x-[90%] -translate-y-[70%] scale-[0.85] opacity-95 rotate-3"
? "translate-x-[90%] -translate-y-[70%] scale-[0.85] opacity-95 rotate-3"
: "translate-x-0 translate-y-0 scale-100 opacity-100 rotate-0"
}`}
aria-label="Home banner"
@ -161,12 +191,12 @@ export default function HomeBanner() {
className="text-white hover:bg-white/20 rounded-full p-2"
aria-label="Dismiss banner"
>
<X className="h-5 w-5" />
<ChevronUp className="h-5 w-5" />
</button>
</div>
{isFlying ? (
<button type="button" onClick={handleBannerClick} className="absolute left-[-15px] bottom-[-20px] h-5 w-5">
<Eclipse className="inset-0 h-5 w-5" />
<button type="button" onClick={handleBannerClick} className="bg-white/50 rounded-full absolute left-[-15px] bottom-[-20px] h-5 w-5">
<ChevronDown className="inset-0 h-5 w-5" />
</button>
) : null}
{eyebrow ? (
@ -208,8 +238,8 @@ export default function HomeBanner() {
</section>
{/* Base content - always present under the banner */}
<div data-alt="home-banner-base" className="relative bg-transparent py-6 min-h-[300px]">
<ChatInputBox noData={false} />
<div data-alt="home-banner-base" className="relative p-6 px-12 min-h-[300px] flex items-center justify-center bg-[radial-gradient(ellipse_at_center,rgba(106,244,249,0.28)_0%,rgba(106,244,249,0.14)_35%,transparent_70%)]">
<VideoCreationForm />
</div>
</div>
);

View File

@ -62,7 +62,7 @@ const MyMovies: React.FC = () => {
return (
<section data-alt="my-movies" className="w-full">
<div data-alt="my-movies-header" className="mb-4 flex items-center justify-between">
<h2 data-alt="my-movies-title" className="text-xl py-4 font-semibold text-white">My Movies</h2>
<h2 data-alt="my-movies-title" className="text-xl py-4 font-semibold text-white">My Projects</h2>
<Link data-alt="all-movies-link" href="/movies" className="text-sm px-2 border rounded-full text-blue-400 hover:text-blue-300">All movies </Link>
</div>

View File

@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
interface TemplatePreviewModalProps {
/** Control visibility */
open: boolean;
/** Video URL to preview */
videoUrl: string | null;
/** Close callback */
onClose: () => void;
/** Optional title shown over the video */
title?: string;
/** Optional description shown over the video */
description?: string;
/** Optional primary action (e.g., Try it) */
onPrimaryAction?: () => void;
/** Optional primary action label */
primaryLabel?: string;
}
/**
* Fullscreen, Tailwind-based template preview modal with video content.
* Overlays title/description if provided and supports an optional primary action.
*/
export function TemplatePreviewModal({
open,
videoUrl,
onClose,
title,
description,
onPrimaryAction,
primaryLabel = 'Try it',
}: TemplatePreviewModalProps) {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!open) {
setIsReady(false);
}
}, [open]);
if (!open || !videoUrl) return null;
return (
<div
data-alt="template-preview-modal"
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={onClose}
>
<div
data-alt="template-preview-modal-content"
className="relative w-[70vw] min-h-[40vw] rounded-lg overflow-hidden border border-white/30 bg-black shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<video
src={videoUrl}
autoPlay
loop
muted
playsInline
onCanPlay={() => setIsReady(true)}
className={`w-full h-auto transition-opacity duration-200 ${isReady ? 'opacity-100' : 'opacity-0'}`}
/>
{!isReady && (
<div
data-alt="template-preview-loading"
className="absolute inset-0 flex items-center justify-center"
>
<div className="h-10 w-10 rounded-full border-2 border-white/30 border-t-white/80 animate-spin" />
</div>
)}
{isReady && (title || description) && (
<div
data-alt="template-preview-header"
className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/90 via-black/80 to-transparent px-6 py-8 text-left"
>
{title && (
<div className="text-bold text-2xl text-white/90 line-clamp-2 mb-2">{title}</div>
)}
</div>
)}
{isReady && onPrimaryAction && (
<div
data-alt="template-preview-footer"
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/80 to-transparent px-8 py-6 text-center"
>
{description && (
<div className="text-base text-white/80 line-clamp-2 text-left">{description}</div>
)}
<button
type="button"
className="inline-flex items-center justify-center rounded-full border border-white/30 bg-white/10 mt-3 px-6 py-2 text-white hover:border-white hover:bg-white hover:text-slate-900"
onClick={onPrimaryAction}
data-alt="template-preview-primary"
>
{primaryLabel}
</button>
</div>
)}
<button
type="button"
data-alt="template-preview-modal-close"
className="absolute top-3 right-3 inline-flex items-center justify-center rounded-full border border-white/30 bg-white/10 p-2 text-white hover:border-white hover:bg-white hover:text-slate-900"
onClick={onClose}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
export default TemplatePreviewModal;

View File

@ -33,7 +33,7 @@ export function PortraitAnimeSelector({
disabled = false,
}: PortraitAnimeSelectorProps) {
const [lastAnimeChoice, setLastAnimeChoice] = useState<string>('STANDARD_V1_734684_116483');
const [animeOptions, setAnimeOptions] = useState<Array<{ name: string; pcode: string }>>([])
const [animeOptions, setAnimeOptions] = useState<Array<{ name: string; pcode: string }>>([]);
useEffect(() => {
let mounted = true;

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { PhotoPreviewSection } from '../PhotoPreview';
import type { PhotoItem, PhotoType } from '../PhotoPreview/types';
import {
@ -11,17 +11,24 @@ import {
ArrowRightOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { Dropdown } from 'antd';
import { Dropdown, Popover } from 'antd';
import { ConfigPanel } from './ConfigPanel';
import { MobileConfigModal } from './MobileConfigModal';
import { AddItemModal } from './AddItemModal';
import { defaultConfig } from './config-options';
import type { ConfigOptions } from './config-options';
import { Eye, Check, ArrowRight, X } from 'lucide-react';
import TemplatePreviewModal from '@/components/common/TemplatePreviewModal';
import { PcTemplateModal } from "@/components/ChatInputBox/PcTemplateModal"
import { useDeviceType } from '@/hooks/useDeviceType';
import { MovieProjectService, MovieProjectMode } from '@/app/service/Interaction/MovieProjectService';
import type { CreateMovieProjectV4Request } from '@/api/DTO/movie_start_dto';
import { getCurrentUser } from '@/lib/auth';
import { useRouter } from 'next/navigation';
import { useTemplateStoryServiceHook } from '@/app/service/Interaction/templateStoryService';
import { StoryTemplateEntity } from '@/app/service/domain/Entities';
import { useUploadFile } from '@/app/service/domain/service';
export default function VideoCreationForm() {
const [photos, setPhotos] = useState<PhotoItem[]>([]);
const [inputText, setInputText] = useState('');
@ -32,12 +39,58 @@ export default function VideoCreationForm() {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [replacingIndex, setReplacingIndex] = useState<number | null>(null);
const [isCreating, setIsCreating] = useState(false);
// Template modal states (align with FamousTemplate usage)
const [isModalOpen, setIsModalOpen] = useState(false);
const [initialTemplateId, setInitialTemplateId] = useState<string | undefined>(undefined);
const [isTemplateCreating, setIsTemplateCreating] = useState(false);
const [isRoleGenerating, setIsRoleGenerating] = useState<{ [key: string]: boolean }>({});
const [isItemGenerating, setIsItemGenerating] = useState<{ [key: string]: boolean }>({});
const [currentTemplate, setCurrentTemplate] = useState<StoryTemplateEntity | null>(null);
const [inputPlaceholder, setInputPlaceholder] = useState<string>('');
const [templateTitle, setTemplateTitle] = useState<string>('');
const [isMentionOpen, setIsMentionOpen] = useState(false);
const { isMobile, isDesktop } = useDeviceType();
const router = useRouter();
/** Template list for mention popover */
const { templateStoryList, getTemplateStoryList } = useTemplateStoryServiceHook();
const isTemplateSelected = useMemo(() => {
return templateStoryList.find(i => i.pcode === configOptions.pcode);
}, [configOptions.pcode, templateStoryList]);
useEffect(() => {
void getTemplateStoryList();
}, [getTemplateStoryList]);
const characterInputRef = useRef<HTMLInputElement>(null);
const sceneInputRef = useRef<HTMLInputElement>(null);
const propInputRef = useRef<HTMLInputElement>(null);
const { uploadFile } = useUploadFile();
/** Clear current template related states */
const clearTemplateSelection = () => {
handleConfigChange('pcode', '');
setInputPlaceholder('');
setTemplateTitle('');
setPhotos([]);
};
/** Apply selected template to current form state */
const applyTemplateSelection = (template: StoryTemplateEntity) => {
const characterPhotos = (template.storyRole || []).map((role, index) => ({
url: role.photo_url,
type: 'character' as const,
id: `character-${Date.now()}-${index}`,
name: role.role_name,
description: role.role_description,
})).filter(p => p.url);
setPhotos(prev => [...prev, ...characterPhotos]);
setInputPlaceholder(template.generateText || template.name);
setTemplateTitle(template.name);
handleConfigChange('pcode', template.pcode || '');
};
/** Handle file upload */
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>, type: PhotoType) => {
@ -47,24 +100,33 @@ export default function VideoCreationForm() {
// Check if we're replacing an existing photo
if (replacingIndex !== null) {
const file = files[0];
void (async () => {
try {
const uploadedUrl = await uploadFile(file);
setPhotos(prevPhotos => {
const updatedPhotos = [...prevPhotos];
updatedPhotos[replacingIndex] = {
...updatedPhotos[replacingIndex],
url: URL.createObjectURL(file),
url: uploadedUrl,
};
return updatedPhotos;
});
} finally {
setReplacingIndex(null);
}
})();
} else {
// Add new photos
const newPhotos: PhotoItem[] = Array.from(files).map((file, index) => ({
url: URL.createObjectURL(file),
void (async () => {
const fileArray = Array.from(files);
const uploadedUrls = await Promise.all(fileArray.map((file) => uploadFile(file)));
const newPhotos: PhotoItem[] = uploadedUrls.map((url, index) => ({
url,
type,
id: `${type}-${Date.now()}-${index}`,
}));
setPhotos(prevPhotos => [...prevPhotos, ...newPhotos]);
})();
}
event.target.value = '';
@ -131,33 +193,36 @@ export default function VideoCreationForm() {
// Check if we're editing or adding
if (typeof data.index === 'number' && editingIndex !== null) {
// Edit mode - update existing photo
void (async () => {
let uploadedUrl: string | undefined;
if (data.file.size > 0) {
uploadedUrl = await uploadFile(data.file);
}
setPhotos(prevPhotos => {
const updatedPhotos = [...prevPhotos];
const existingPhoto = updatedPhotos[editingIndex];
updatedPhotos[editingIndex] = {
...existingPhoto,
// Only update URL if a new file was selected (not the dummy file)
...(data.file.size > 0 && { url: URL.createObjectURL(data.file) }),
...(uploadedUrl ? { url: uploadedUrl } : {}),
...(data.name && { name: data.name }),
...(data.description && { description: data.description }),
};
return updatedPhotos;
});
console.log('Updated item:', { ...data, type: currentItemType, index: editingIndex });
})();
} else {
// Add mode - create new photo
void (async () => {
const uploadedUrl = await uploadFile(data.file);
const newPhoto: PhotoItem = {
url: URL.createObjectURL(data.file),
url: uploadedUrl,
type: currentItemType,
id: `${currentItemType}-${Date.now()}`,
...(data.name && { name: data.name }),
...(data.description && { description: data.description }),
};
setPhotos(prevPhotos => [...prevPhotos, newPhoto]);
console.log('Added item:', { ...data, type: currentItemType });
})();
}
};
@ -193,7 +258,7 @@ export default function VideoCreationForm() {
aspect_ratio: configOptions.aspect_ratio,
expansion_mode: configOptions.expansion_mode,
video_duration: configOptions.videoDuration,
is_image_to_video: photos.length > 0,
use_img2video: photos.length > 0,
pcode: configOptions.pcode === 'portrait' ? '' : configOptions.pcode,
};
@ -257,9 +322,25 @@ export default function VideoCreationForm() {
data-alt="content-container"
className="flex-1 border border-white/10 rounded-3xl bg-gradient-to-br from-[#1a1a1a]/50 to-[#0a0a0a]/50 backdrop-blur-sm overflow-hidden flex flex-col"
>
{templateTitle && (
<div data-alt="template-title" className="px-4 pt-2 flex items-center">
<div data-alt="template-title-text" className="text-white text-xs font-bold truncate">
{templateTitle}
</div>
<button
data-alt="clear-template"
className="ml-2 p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white"
onClick={() => {
clearTemplateSelection();
}}
>
<X className="w-3 h-3" />
</button>
</div>
)}
{/* Photo Preview Section - Top */}
{photos.length > 0 && (
<div data-alt="photo-preview-wrapper" className="p-4 pb-0">
<div data-alt="photo-preview-wrapper" className="pt-2 pl-4 pb-0">
<PhotoPreviewSection
photos={photos}
onEdit={handleEditPhoto}
@ -273,7 +354,7 @@ export default function VideoCreationForm() {
<textarea
data-alt="main-text-input"
className="w-full h-full bg-transparent text-gray-300 text-base placeholder-gray-500 resize-none outline-none border-none"
placeholder="Share a topic, idea, or instructions with Video Agent to produce a full avatar video"
placeholder={inputPlaceholder || "Share a topic, idea, or instructions with Video Agent to produce a full avatar video"}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
@ -344,13 +425,75 @@ export default function VideoCreationForm() {
onChange={(e) => handleFileUpload(e, 'prop')}
/>
{/* Mention Button */}
{/* Mention Button with Popover (template list) */}
<Popover
placement="topLeft"
trigger={["click"]}
arrow={false}
open={isMentionOpen}
onOpenChange={(open) => setIsMentionOpen(open)}
content={
<div
data-alt="mention-popover-content"
className="w-64 max-h-64 overflow-y-auto"
>
{templateStoryList && templateStoryList.length > 0 ? (
<div className="grid grid-cols-1">
{templateStoryList.map((tpl) => {
const tplKey = (tpl as any).id || (tpl as any).template_id;
return (
<div
key={tplKey}
data-alt={`mention-template-item-${tplKey}`}
className="group relative px-3 py-2 rounded-lg text-gray-200 hover:bg-white/10 transition-colors text-sm"
>
<div className={`flex items-center gap-3 cursor-pointer ${tpl.pcode === configOptions.pcode ? 'text-custom-blue' : ''}`}
onClick={() => {
setIsMentionOpen(false);
const url = (tpl as any).show_url as string | undefined;
if (url) {
setCurrentTemplate(tpl as StoryTemplateEntity);
} else {
setInitialTemplateId(tplKey);
setIsModalOpen(true);
}
}}
>
<span className="truncate">{tpl.name}</span>
{/* Floating actions on hover, right-aligned and overlaying the name */}
<div className="absolute px-2 right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity z-10">
<button
data-alt={`mention-template-use-${tplKey}`}
className="px-0 py-1 text-xs rounded-full hover:text-custom-blue/80"
onClick={(e) => {
e.stopPropagation();
setIsMentionOpen(false);
clearTemplateSelection();
const selected = tpl as StoryTemplateEntity;
applyTemplateSelection(selected);
}}
>
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<div data-alt="mention-no-templates" className="text-gray-400 text-sm">No Avaliable Templates</div>
)}
</div>
}
>
<button
data-alt="mention-button"
className="w-8 h-8 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-gray-300 hover:text-cyan-400"
className={`w-8 h-8 rounded-full border border-white/20 bg-transparent hover:bg-white/5 hover:border-cyan-400/60 transition-all duration-200 flex items-center justify-center text-gray-300 hover:text-cyan-400 ${isTemplateSelected ? 'text-yellow-500' : ''}`}
>
<span className="text-base font-bold">@</span>
</button>
</Popover>
{/* Configuration - Desktop: Full Panel, Mobile: Setting Icon */}
{isDesktop ? (
@ -416,6 +559,32 @@ export default function VideoCreationForm() {
: undefined
}
/>
<PcTemplateModal
isTemplateCreating={isTemplateCreating}
setIsTemplateCreating={setIsTemplateCreating}
isRoleGenerating={isRoleGenerating}
setIsRoleGenerating={setIsRoleGenerating}
isItemGenerating={isItemGenerating}
setIsItemGenerating={setIsItemGenerating}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
initialTemplateId={initialTemplateId}
configOptions={{ mode: "auto", resolution: "720p", language: "english", videoDuration: "auto" }}
/>
<TemplatePreviewModal
open={!!currentTemplate && !!currentTemplate.show_url}
videoUrl={currentTemplate?.show_url || ''}
onClose={() => setCurrentTemplate(null)}
title={currentTemplate?.name || ''}
description={currentTemplate?.generateText || currentTemplate?.name}
onPrimaryAction={() => {
if (!currentTemplate) return;
clearTemplateSelection();
applyTemplateSelection(currentTemplate);
setCurrentTemplate(null)
}}
primaryLabel="Try it Free"
/>
</div>
);
}

View File

@ -93,11 +93,10 @@ export default function PhotoPreviewSection({
{/* Photo List Container with Horizontal Scroll */}
<div
data-alt="photo-list-container"
className="flex gap-2 overflow-x-auto overflow-y-hidden photo-list-scrollbar pb-1"
className="flex gap-2 overflow-x-auto overflow-y-hidden photo-list-scrollbar"
>
{photos.map((photo, index) => {
const photoName = photo.name || `${getTypeLabel(photo.type)} ${index + 1}`;
return (
<div
key={photo.id || `photo-${index}`}