forked from 77media/video-flow
Merge branch 'dev' of https://git.qikongjian.com/77media/video-flow into dev
This commit is contained in:
commit
12932a9f67
@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2 } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2, Globe } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import './style/create-to-video2.css';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
@ -14,7 +12,6 @@ import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
|
||||
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||||
import { ScriptEditDialog } from '@/components/script-edit-dialog';
|
||||
|
||||
|
||||
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
|
||||
@ -32,6 +29,7 @@ export function CreateToVideo2() {
|
||||
const [isFocus, setIsFocus] = useState(false);
|
||||
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
|
||||
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>('en');
|
||||
const [script, setInputText] = useState('');
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [runTour, setRunTour] = useState(true);
|
||||
@ -45,15 +43,12 @@ export function CreateToVideo2() {
|
||||
const [limit, setLimit] = useState(12);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('created_at');
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isSmartAssistantExpanded, setIsSmartAssistantExpanded] = useState(false);
|
||||
const [userId, setUserId] = useState<number>(0);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [isScriptEditDialogOpen, setIsScriptEditDialogOpen] = useState(false);
|
||||
const [loadingIdea, setLoadingIdea] = useState(false);
|
||||
|
||||
// 在客户端挂载后读取localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@ -135,8 +130,6 @@ export function CreateToVideo2() {
|
||||
}
|
||||
|
||||
const handleCreateVideo = async () => {
|
||||
setIsScriptEditDialogOpen(true);
|
||||
return;
|
||||
setIsCreating(true);
|
||||
// 创建剧集数据
|
||||
let episodeData: any = {
|
||||
@ -242,6 +235,53 @@ export function CreateToVideo2() {
|
||||
},
|
||||
];
|
||||
|
||||
// 语言选项配置
|
||||
const languageItems: MenuProps['items'] = [
|
||||
{
|
||||
type: 'group',
|
||||
label: (
|
||||
<div className="text-white/50 text-xs px-2 pb-2">Language</div>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
key: 'en',
|
||||
label: (
|
||||
<div className="flex items-center justify-between p-1">
|
||||
<span className="text-base">English</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'zh',
|
||||
label: (
|
||||
<div className="flex items-center justify-between p-1">
|
||||
<span className="text-base">Chinese</span>
|
||||
<Crown className="w-4 h-4 text-yellow-500" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ja',
|
||||
label: (
|
||||
<div className="flex items-center justify-between p-1">
|
||||
<span className="text-base">Japanese</span>
|
||||
<Crown className="w-4 h-4 text-yellow-500" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ko',
|
||||
label: (
|
||||
<div className="flex items-center justify-between p-1">
|
||||
<span className="text-base">Korean</span>
|
||||
<Crown className="w-4 h-4 text-yellow-500" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 处理模式选择
|
||||
const handleModeSelect: MenuProps['onClick'] = ({ key }) => {
|
||||
setSelectedMode(key as ModeEnum);
|
||||
@ -252,6 +292,21 @@ export function CreateToVideo2() {
|
||||
setSelectedResolution(key as ResolutionEnum);
|
||||
};
|
||||
|
||||
// 处理语言选择
|
||||
const handleLanguageSelect: MenuProps['onClick'] = ({ key }) => {
|
||||
setSelectedLanguage(key as string);
|
||||
};
|
||||
|
||||
// 处理获取想法
|
||||
const handleGetIdea = () => {
|
||||
if (loadingIdea) return;
|
||||
setLoadingIdea(true);
|
||||
setTimeout(() => {
|
||||
setInputText('idea');
|
||||
setLoadingIdea(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleStartCreating = () => {
|
||||
setActiveTab('script');
|
||||
setInputText(ideaText);
|
||||
@ -402,6 +457,8 @@ export function CreateToVideo2() {
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
loop
|
||||
preload="none"
|
||||
poster={`${episode.final_video_url}?vframe/jpg/offset/1`}
|
||||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play()}
|
||||
onMouseLeave={(e) => (e.target as HTMLVideoElement).pause()}
|
||||
/>
|
||||
@ -618,9 +675,15 @@ export function CreateToVideo2() {
|
||||
<span>Describe the content you want to action. Get an </span>
|
||||
<b
|
||||
className='idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal cursor-pointer pointer-events-auto underline'
|
||||
onClick={() => setInputText(ideaText)}
|
||||
onClick={() => handleGetIdea()}
|
||||
>
|
||||
<Lightbulb className='w-4 h-4' />idea
|
||||
{loadingIdea ? (
|
||||
<Loader2 className='w-4 h-4 animate-spin' />
|
||||
) : (
|
||||
<>
|
||||
<Lightbulb className='w-4 h-4' />idea
|
||||
</>
|
||||
)}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
@ -669,6 +732,28 @@ export function CreateToVideo2() {
|
||||
<Crown className={`w-4 h-4 text-yellow-500 ${selectedResolution === ResolutionEnum.HD_720P ? 'hidden' : ''}`} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{/* 语言 */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: languageItems,
|
||||
onClick: handleLanguageSelect,
|
||||
selectedKeys: [selectedLanguage.toString()],
|
||||
}}
|
||||
trigger={['click']}
|
||||
overlayClassName="mode-dropdown"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<div className='tool-operation-button ant-dropdown-trigger'>
|
||||
<Globe className='w-4 h-4' />
|
||||
<span className='text-nowrap opacity-70'>
|
||||
{selectedLanguage === 'en' ? 'English' :
|
||||
selectedLanguage === 'zh' ? 'Chinese' :
|
||||
selectedLanguage === 'ja' ? 'Japanese' : 'Korean'}
|
||||
</span>
|
||||
<Crown className={`w-4 h-4 text-yellow-500 ${selectedLanguage === 'en' ? 'hidden' : ''}`} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -698,13 +783,6 @@ export function CreateToVideo2() {
|
||||
<EmptyStateAnimation className='' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScriptEditDialogOpen && (
|
||||
<ScriptEditDialog
|
||||
isOpen={isScriptEditDialogOpen}
|
||||
onClose={() => setIsScriptEditDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import { MediaViewer } from "./work-flow/media-viewer";
|
||||
import { ThumbnailGrid } from "./work-flow/thumbnail-grid";
|
||||
import { useWorkflowData } from "./work-flow/use-workflow-data";
|
||||
import { usePlaybackControls } from "./work-flow/use-playback-controls";
|
||||
import { AlertCircle, RefreshCw, Pause, Play } from "lucide-react";
|
||||
import { AlertCircle, RefreshCw, Pause, Play, ChevronLast } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||
|
||||
@ -22,6 +22,7 @@ export default function WorkFlow() {
|
||||
// 使用自定义 hooks 管理状态
|
||||
const {
|
||||
taskObject,
|
||||
scriptData,
|
||||
taskSketch,
|
||||
taskScenes,
|
||||
taskShotSketch,
|
||||
@ -41,6 +42,7 @@ export default function WorkFlow() {
|
||||
setCurrentSketchIndex,
|
||||
retryLoadData,
|
||||
isPauseWorkFlow,
|
||||
mode,
|
||||
setIsPauseWorkFlow,
|
||||
} = useWorkflowData();
|
||||
|
||||
@ -170,6 +172,7 @@ export default function WorkFlow() {
|
||||
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
|
||||
<ErrorBoundary>
|
||||
<MediaViewer
|
||||
scriptData={scriptData}
|
||||
currentStep={currentStep}
|
||||
currentSketchIndex={currentSketchIndex}
|
||||
taskSketch={taskSketch}
|
||||
@ -212,14 +215,26 @@ export default function WorkFlow() {
|
||||
</div>
|
||||
|
||||
{/* 暂停/播放按钮 */}
|
||||
<div className="absolute right-12 bottom-12 z-[9999]">
|
||||
<GlassIconButton
|
||||
icon={isPauseWorkFlow ? Play : Pause}
|
||||
size='lg'
|
||||
tooltip={isPauseWorkFlow ? "Play" : "Pause"}
|
||||
onClick={() => setIsPauseWorkFlow(!isPauseWorkFlow)}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
currentStep !== '6' && (
|
||||
<div className="absolute right-12 bottom-16 z-[9999] flex gap-4">
|
||||
<GlassIconButton
|
||||
icon={isPauseWorkFlow ? Play : Pause}
|
||||
size='lg'
|
||||
tooltip={isPauseWorkFlow ? "Play" : "Pause"}
|
||||
onClick={() => setIsPauseWorkFlow(!isPauseWorkFlow)}
|
||||
/>
|
||||
{ mode !== 'auto' && (
|
||||
<GlassIconButton
|
||||
icon={ChevronLast}
|
||||
size='lg'
|
||||
tooltip="Next"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
{/* AI 建议栏 */}
|
||||
<ErrorBoundary>
|
||||
|
||||
@ -7,8 +7,10 @@ import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||||
import { mockScriptData } from '@/components/script-renderer/mock';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface MediaViewerProps {
|
||||
scriptData: any;
|
||||
currentStep: string;
|
||||
currentSketchIndex: number;
|
||||
taskSketch: any[];
|
||||
@ -26,6 +28,7 @@ interface MediaViewerProps {
|
||||
}
|
||||
|
||||
export function MediaViewer({
|
||||
scriptData,
|
||||
currentStep,
|
||||
currentSketchIndex,
|
||||
taskSketch,
|
||||
@ -801,7 +804,22 @@ export function MediaViewer({
|
||||
const renderScriptContent = () => {
|
||||
return (
|
||||
<div className="relative w-full h-full bg-white/10 rounded-lg overflow-hidden p-2">
|
||||
<ScriptRenderer data={mockScriptData} />
|
||||
{
|
||||
scriptData ? (
|
||||
<ScriptRenderer data={mockScriptData} />
|
||||
) : (
|
||||
<div className="flex gap-2 w-full h-full">
|
||||
<div className="w-[70%] h-full rounded-lg gap-2 flex flex-col">
|
||||
<Skeleton className="w-full h-[33%] rounded-lg" />
|
||||
<Skeleton className="w-full h-[33%] rounded-lg" />
|
||||
<Skeleton className="w-full h-[33%] rounded-lg" />
|
||||
</div>
|
||||
<div className="w-[30%] h-full rounded-lg">
|
||||
<Skeleton className="w-full h-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -57,6 +57,7 @@ export function useWorkflowData() {
|
||||
|
||||
// 更新 taskObject 的类型
|
||||
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
|
||||
const [scriptData, setScriptData] = useState<any>(null);
|
||||
const [taskSketch, setTaskSketch] = useState<any[]>([]);
|
||||
const [taskScenes, setTaskScenes] = useState<any[]>([]);
|
||||
const [taskShotSketch, setTaskShotSketch] = useState<any[]>([]);
|
||||
@ -74,6 +75,7 @@ export function useWorkflowData() {
|
||||
const [dataLoadError, setDataLoadError] = useState<string | null>(null);
|
||||
const [needStreamData, setNeedStreamData] = useState(false);
|
||||
const [isPauseWorkFlow, setIsPauseWorkFlow] = useState(false);
|
||||
const [mode, setMode] = useState<'auto' | 'manual'>('manual');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { sketchCount, videoCount } = useAppSelector((state) => state.workflow);
|
||||
@ -554,6 +556,7 @@ export function useWorkflowData() {
|
||||
|
||||
return {
|
||||
taskObject,
|
||||
scriptData,
|
||||
taskSketch,
|
||||
taskScenes,
|
||||
taskShotSketch,
|
||||
@ -574,6 +577,7 @@ export function useWorkflowData() {
|
||||
retryLoadData,
|
||||
handleManualPlay,
|
||||
isPauseWorkFlow,
|
||||
mode,
|
||||
setIsPauseWorkFlow,
|
||||
};
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useRef, useState, useMemo, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { SquarePen, Lightbulb, Navigation, Globe, Copy, SendHorizontal } from 'lucide-react';
|
||||
import { ScriptData, ScriptBlock, ScriptContent } from './types';
|
||||
import { SquarePen, Lightbulb, Navigation, Globe, Copy, SendHorizontal, X, Plus } from 'lucide-react';
|
||||
import { ScriptData, ScriptBlock, ScriptContent, ThemeTagBgColor, ThemeType } from './types';
|
||||
import ContentEditable, { ContentEditableEvent } from 'react-contenteditable';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { SelectDropdown } from '@/components/ui/select-dropdown';
|
||||
import { TypewriterText } from '@/components/workflow/work-office/common/TypewriterText';
|
||||
|
||||
interface ScriptRendererProps {
|
||||
data: ScriptData;
|
||||
@ -16,6 +17,12 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
const contentRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
const [editBlockId, setEditBlockId] = useState<string | null>(null);
|
||||
const contentEditableRef = useRef<HTMLElement>(null);
|
||||
const [addThemeTag, setAddThemeTag] = useState<string | null>(null);
|
||||
const [isInit, setIsInit] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setEditBlockId(null);
|
||||
}, [activeBlockId]);
|
||||
|
||||
const scrollToBlock = (blockId: string) => {
|
||||
const element = contentRefs.current[blockId];
|
||||
@ -25,6 +32,14 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 useMemo 缓存标签颜色映射
|
||||
const randomThemeTagBgColor = useMemo(() => {
|
||||
return Object.values(ThemeTagBgColor).reduce((acc: Record<string, string>, color: string) => {
|
||||
acc[color] = color;
|
||||
return acc;
|
||||
}, {});
|
||||
}, []);
|
||||
|
||||
// 用于渲染展示的 JSX
|
||||
const renderContent = (content: ScriptContent) => {
|
||||
switch (content.type) {
|
||||
@ -35,7 +50,15 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
case 'italic':
|
||||
return <em className="italic">{content.text}</em>;
|
||||
default:
|
||||
return <p className="mb-2">{content.text}</p>;
|
||||
return <p className="mb-2">
|
||||
{
|
||||
isInit ? (
|
||||
<TypewriterText text={content.text || ''} stableId={content.type} />
|
||||
) : (
|
||||
<span>{content.text}</span>
|
||||
)
|
||||
}
|
||||
</p>;
|
||||
}
|
||||
};
|
||||
|
||||
@ -65,6 +88,11 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
console.log(e.target.value);
|
||||
};
|
||||
|
||||
const handleBlockTextBlur = (block: ScriptBlock) => (e: ContentEditableEvent) => {
|
||||
console.log(e.target.value);
|
||||
setEditBlockId(null);
|
||||
};
|
||||
|
||||
const renderEditBlock = (block: ScriptBlock) => {
|
||||
let blockHtmlText = '';
|
||||
block.content.forEach(item => {
|
||||
@ -76,6 +104,8 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
innerRef={contentEditableRef}
|
||||
html={formatTextToHtml(blockHtmlText)}
|
||||
onChange={handleBlockTextChange(block)}
|
||||
onBlur={handleBlockTextBlur(block)}
|
||||
autoFocus={true}
|
||||
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
||||
rounded-lg border-unset outline-none pb-12
|
||||
whitespace-pre-wrap break-words"
|
||||
@ -84,6 +114,78 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderTypeBlock = (block: ScriptBlock, isHovered: boolean, isActive: boolean, isEditing: boolean) => {
|
||||
switch (block.type) {
|
||||
case 'theme':
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{block.content.map((item, index) => (
|
||||
<div key={index} className={`flex items-center gap-1 px-2 rounded-full ${Object.values(ThemeTagBgColor)[index]}`}>
|
||||
<span className={`text-sm px-2 py-1 rounded-md`}>{item.text}</span>
|
||||
<X className="w-4 h-4 cursor-pointer text-blue-500/80" onClick={() => console.log(item.text)} />
|
||||
</div>
|
||||
))}
|
||||
{/* 新增主题标签 */}
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className='w-[10rem]'>
|
||||
<SelectDropdown
|
||||
dropdownId="theme-type"
|
||||
label=""
|
||||
options={Object.values(ThemeType).map(type => ({ label: type, value: type }))}
|
||||
value={addThemeTag}
|
||||
placeholder="Select Theme Type"
|
||||
onChange={() => console.log('主题类型')}
|
||||
/>
|
||||
</div>
|
||||
<button className='p-2 rounded-full bg-white/5 backdrop-blur-md'>
|
||||
<Plus className="w-4 h-4 cursor-pointer text-white-600" onClick={() => console.log('新增主题标签')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{(isHovered || isActive) && (
|
||||
<motion.div
|
||||
className="absolute top-4 right-4 flex gap-2"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<SquarePen
|
||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||
onClick={() => {
|
||||
setEditBlockId(block.id);
|
||||
setActiveBlockId(block.id);
|
||||
setIsInit(false);
|
||||
}}
|
||||
/>
|
||||
<Copy
|
||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(block.content.map(item => item.text).join('\n'));
|
||||
toast.success('Copied!');
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="leading-relaxed">
|
||||
{isEditing ? (
|
||||
renderEditBlock(block)
|
||||
) : (
|
||||
block.content.map((item, index) => (
|
||||
<div key={index}>{renderContent(item)}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBlock = (block: ScriptBlock) => {
|
||||
const isHovered = hoveredBlockId === block.id;
|
||||
const isActive = activeBlockId === block.id;
|
||||
@ -93,7 +195,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
<motion.div
|
||||
key={block.id}
|
||||
className={`relative p-4 mb-1 rounded-lg shadow-md transition-colors duration-300
|
||||
${isActive ? 'bg-blue-500/20' : ''} hover:bg-blue-500/10`}
|
||||
${isActive ? 'bg-slate-700/50' : ''} hover:bg-slate-700/30`}
|
||||
ref={(el) => (contentRefs.current[block.id] = el)}
|
||||
onMouseEnter={() => setHoveredBlockId(block.id)}
|
||||
onMouseLeave={() => setHoveredBlockId(null)}
|
||||
@ -102,40 +204,9 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold mb-1 text-blue-500">{block.title}</h2>
|
||||
<AnimatePresence>
|
||||
{(isHovered || isActive) && (
|
||||
<motion.div
|
||||
className="absolute top-4 right-4 flex gap-2"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
>
|
||||
<SquarePen
|
||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||
onClick={() => {
|
||||
setEditBlockId(block.id);
|
||||
setActiveBlockId(block.id);
|
||||
}}
|
||||
/>
|
||||
<Copy
|
||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(block.content.map(item => item.text).join('\n'));
|
||||
toast.success('Copied!');
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="leading-relaxed">
|
||||
{isEditing ? (
|
||||
renderEditBlock(block)
|
||||
) : (
|
||||
block.content.map((item, index) => (
|
||||
<div key={index}>{renderContent(item)}</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
renderTypeBlock(block, isHovered, isActive, isEditing)
|
||||
}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -4,16 +4,54 @@ export const mockScriptData: ScriptData = {
|
||||
blocks: [
|
||||
{
|
||||
id: 'core',
|
||||
title: "SCENE'S CORE CONFLICT",
|
||||
title: "SUMMARY",
|
||||
type: 'core',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: 'The desperate, on-stage improvisation of a failing comedian triggers a physical and professional collapse, forcing him and his partner to confront the absurd meaninglessness of their ambitions. This scene serves as the inciting incident and rising action, where the internal conflict of artistic decay manifests as a literal, catastrophic failure, propelling them from a state of professional anxiety into a fight for survival.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'theme',
|
||||
title: 'THEME',
|
||||
type: 'theme',
|
||||
content: [
|
||||
{
|
||||
type: 'tag',
|
||||
text: 'Satire'
|
||||
},
|
||||
{
|
||||
type: 'tag',
|
||||
text: 'Absurdist Comedy'
|
||||
},
|
||||
{
|
||||
type: 'tag',
|
||||
text: 'Disaster'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'roles',
|
||||
title: 'ROLES',
|
||||
type: 'roles',
|
||||
content: [
|
||||
{
|
||||
type: 'bold',
|
||||
text: 'Anna'
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: 'Anna is a young woman who is trying to find her place in the world. She is a bit of a mess, but she is also a bit of a mess.'
|
||||
},
|
||||
{
|
||||
type: 'bold',
|
||||
text: 'GENRE: Satire | Absurdist Comedy | Disaster'
|
||||
text: 'Mark'
|
||||
},
|
||||
{
|
||||
type: 'card',
|
||||
text: 'Mark starts as resentful and aimless, harboring guilt over past family conflicts. His involvement in Anna’s quest rekindles his sense of responsibility and belonging. He moves from avoidance and cynicism to active support, ultimately risking his own safety for Anna and the creature. Mark’s arc is one of redemption and reconnection.'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -2,13 +2,20 @@ export interface ScriptBlock {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ScriptContent[];
|
||||
type: 'core' | 'scene' | 'summary';
|
||||
type: 'core' | 'scene' | 'summary' | 'theme' | 'roles';
|
||||
sceneNumber?: number;
|
||||
}
|
||||
|
||||
export interface ScriptContent {
|
||||
type: 'paragraph' | 'bold' | 'italic' | 'heading';
|
||||
text: string;
|
||||
type: 'paragraph' | 'bold' | 'italic' | 'heading' | 'tag' | 'card';
|
||||
text?: string;
|
||||
roleInfo?: {
|
||||
name: string;
|
||||
gender: string;
|
||||
role: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScriptData {
|
||||
@ -23,4 +30,33 @@ export interface NavigationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'core' | 'scene' | 'summary';
|
||||
}
|
||||
|
||||
// 主题标签背景色 enum
|
||||
export enum ThemeTagBgColor {
|
||||
blue = 'bg-blue-500/20',
|
||||
green = 'bg-green-500/20',
|
||||
red = 'bg-red-500/20',
|
||||
yellow = 'bg-yellow-500/20',
|
||||
purple = 'bg-purple-500/20',
|
||||
orange = 'bg-orange-500/20',
|
||||
pink = 'bg-pink-500/20',
|
||||
brown = 'bg-brown-500/20',
|
||||
gray = 'bg-gray-500/20'
|
||||
}
|
||||
|
||||
// 主题类型 enum
|
||||
export enum ThemeType {
|
||||
satire = 'satire', // 讽刺
|
||||
absurdistComedy = 'absurdistComedy', // 荒诞喜剧
|
||||
disaster = 'disaster', // 灾难
|
||||
tragedy = 'tragedy', // 悲剧
|
||||
comedy = 'comedy', // 喜剧
|
||||
drama = 'drama', // 戏剧
|
||||
fantasy = 'fantasy', // 奇幻
|
||||
horror = 'horror', // 恐怖
|
||||
mystery = 'mystery', // 神秘
|
||||
romance = 'romance', // 爱情
|
||||
scienceFiction = 'scienceFiction', // 科幻
|
||||
thriller = 'thriller', // 惊悚
|
||||
}
|
||||
@ -13,6 +13,7 @@ interface SelectDropdownProps {
|
||||
label: string;
|
||||
options: SettingOption[];
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
@ -22,6 +23,7 @@ export const SelectDropdown = (
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
placeholder,
|
||||
onChange
|
||||
}: SelectDropdownProps
|
||||
) => {
|
||||
@ -35,7 +37,7 @@ export const SelectDropdown = (
|
||||
<div className="relative w-full h-full">
|
||||
<motion.button
|
||||
className={cn(
|
||||
"w-full px-4 py-2 rounded-lg border text-left flex items-center justify-between",
|
||||
"w-full p-2 rounded-lg border text-left flex items-center justify-between",
|
||||
openDropdown === dropdownId
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: "border-white/10 hover:border-white/20"
|
||||
@ -44,7 +46,10 @@ export const SelectDropdown = (
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<span>{options.find(opt => opt.value === value)?.label || value}</span>
|
||||
<div className="flex items-center gap-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<span>{options.find(opt => opt.value === value)?.label || value}</span>
|
||||
{placeholder && <span className="text-gray-400/60">{placeholder}</span>}
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: openDropdown === dropdownId ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user