304 lines
18 KiB
TypeScript
304 lines
18 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { ProjectStructure, StructureItem, ProjectType } from '../types';
|
|
import { Film, Edit2, Trash2, Plus, Save, Eye, Dices, X, Sparkles, Clock, Zap, Terminal, ChevronDown, ChevronRight } from 'lucide-react';
|
|
import { optimizeVisualPrompt } from '../services/geminiService';
|
|
|
|
interface StructurePlannerProps {
|
|
structure: ProjectStructure;
|
|
type: ProjectType;
|
|
onSelectEpisode: (item: StructureItem) => void;
|
|
onUpdateStructure: (newStructure: ProjectStructure) => void;
|
|
onReroll: () => void;
|
|
isGenerating: boolean;
|
|
producingItemId: string | null;
|
|
modelSettings: any; // Using any to avoid circular type ref, should be ModelSettings
|
|
}
|
|
|
|
const StructurePlanner: React.FC<StructurePlannerProps> = ({ structure, type, onSelectEpisode, onUpdateStructure, onReroll, isGenerating, producingItemId, modelSettings }) => {
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editForm, setEditForm] = useState<StructureItem | null>(null);
|
|
const [editMode, setEditMode] = useState<'auto' | 'pro'>('auto'); // New: Auto vs Pro mode
|
|
const [isOptimizing, setIsOptimizing] = useState(false);
|
|
|
|
const getHeaderIcon = () => {
|
|
switch (type) {
|
|
case ProjectType.MUSIC_VIDEO: return <MusicIcon className="w-6 h-6 text-purple-500" />;
|
|
case ProjectType.VIRTUAL_INFLUENCER: return <SmartphoneIcon className="w-6 h-6 text-pink-500" />;
|
|
default: return <Film className="w-6 h-6 text-red-500" />;
|
|
}
|
|
};
|
|
|
|
const getItemLabel = () => {
|
|
switch (type) {
|
|
case ProjectType.MUSIC_VIDEO: return "段落 (Beat)";
|
|
case ProjectType.VIRTUAL_INFLUENCER: return "貼文 (Post)";
|
|
default: return "集數 (Episode)";
|
|
}
|
|
};
|
|
|
|
const handleEditClick = (item: StructureItem) => {
|
|
setEditingId(item.id);
|
|
setEditForm({ ...item });
|
|
setEditMode('auto'); // Default to auto mode on open
|
|
};
|
|
|
|
const handleSaveEdit = () => {
|
|
if (editForm) {
|
|
const newItems = structure.items.map(i => i.id === editForm.id ? editForm : i);
|
|
onUpdateStructure({ ...structure, items: newItems });
|
|
setEditingId(null);
|
|
setEditForm(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = (id: string) => {
|
|
if (confirm('確定要刪除這個項目嗎?')) {
|
|
const newItems = structure.items.filter(i => i.id !== id);
|
|
onUpdateStructure({ ...structure, items: newItems });
|
|
}
|
|
};
|
|
|
|
const handleAdd = () => {
|
|
const newItem: StructureItem = {
|
|
id: `custom-${Date.now()}`,
|
|
title: '新項目',
|
|
summary: '',
|
|
visualConcept: '',
|
|
estimatedDuration: '1 min'
|
|
};
|
|
onUpdateStructure({ ...structure, items: [...structure.items, newItem] });
|
|
setEditingId(newItem.id);
|
|
setEditForm(newItem);
|
|
setEditMode('auto');
|
|
};
|
|
|
|
const handleOptimizePrompt = async () => {
|
|
if (!editForm) return;
|
|
if (!modelSettings.googleApiKey) {
|
|
alert("請輸入 Google API Key");
|
|
return;
|
|
}
|
|
setIsOptimizing(true);
|
|
try {
|
|
const refined = await optimizeVisualPrompt(editForm.summary, editForm.visualConcept, modelSettings.textModel, modelSettings.googleApiKey);
|
|
setEditForm({ ...editForm, visualConcept: refined });
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setIsOptimizing(false);
|
|
}
|
|
};
|
|
|
|
// Helper to parse duration string "45 min" -> ["45", "min"]
|
|
const parseDuration = (dur: string = '1 min') => {
|
|
const match = dur.match(/(\d+)\s*(min|sec)/);
|
|
if (match) return { val: match[1], unit: match[2] };
|
|
return { val: '1', unit: 'min' };
|
|
};
|
|
|
|
const updateDuration = (val: string, unit: string) => {
|
|
if (!editForm) return;
|
|
setEditForm({ ...editForm, estimatedDuration: `${val} ${unit}` });
|
|
};
|
|
|
|
return (
|
|
<div className="animate-fade-in space-y-8 pb-32 max-w-full">
|
|
|
|
{/* Series Header */}
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 text-center relative overflow-hidden group hover:border-indigo-500/30 transition-colors">
|
|
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500" />
|
|
<h2 className="text-3xl font-bold text-white mb-2 text-glow break-all max-w-full">{structure.title}</h2>
|
|
<p className="text-zinc-400 max-w-2xl mx-auto italic break-all">{structure.logline}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<h3 className="text-xl font-bold text-white flex items-center gap-2">
|
|
{getHeaderIcon()}
|
|
{type === ProjectType.NETFLIX_SERIES ? '季/集規劃 (Season Breakdown)' : '內容規劃 (Content Plan)'}
|
|
</h3>
|
|
<button
|
|
onClick={onReroll}
|
|
disabled={isGenerating}
|
|
className="glass-btn px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-2 text-zinc-400 hover:text-white shrink-0"
|
|
title="換一批 (隨機重骰)"
|
|
>
|
|
<Dices className={`w-4 h-4 ${isGenerating ? 'animate-spin' : ''}`} />
|
|
<span>{isGenerating ? '列表更新中...' : '換一批'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleAdd}
|
|
className="glass-btn px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-2 text-emerald-400 hover:bg-emerald-500/10 shrink-0"
|
|
>
|
|
<Plus className="w-4 h-4" /> 新增項目
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{structure.items.map((item, index) => {
|
|
const isEditing = editingId === item.id;
|
|
const isProducing = producingItemId === item.id;
|
|
|
|
return (
|
|
<div key={item.id} className={`bg-black border rounded-xl overflow-hidden transition-all w-full max-w-full ${isEditing ? 'border-indigo-500 shadow-[0_0_20px_rgba(99,102,241,0.2)]' : 'border-zinc-800 hover:border-zinc-600'} ${isProducing ? 'ring-2 ring-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]' : ''}`}>
|
|
<div className="p-6">
|
|
{isEditing && editForm ? (
|
|
// EDIT MODE
|
|
<div className="space-y-4 max-w-full">
|
|
<div className="flex flex-wrap justify-between items-center mb-2 pb-2 border-b border-white/5 gap-2">
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
onClick={() => setEditMode('auto')}
|
|
className={`px-3 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors flex items-center gap-1 ${editMode === 'auto' ? 'bg-indigo-500 text-white' : 'text-zinc-500 hover:text-white'}`}
|
|
>
|
|
<Zap className="w-3 h-3" /> 自動模式
|
|
</button>
|
|
<button
|
|
onClick={() => setEditMode('pro')}
|
|
className={`px-3 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider transition-colors flex items-center gap-1 ${editMode === 'pro' ? 'bg-purple-500 text-white' : 'text-zinc-500 hover:text-white'}`}
|
|
>
|
|
<Terminal className="w-3 h-3" /> 專業模式
|
|
</button>
|
|
</div>
|
|
<button onClick={() => setEditingId(null)} className="text-zinc-500 hover:text-white"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<label className="text-[10px] text-zinc-500 uppercase font-bold">標題 (中文)</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.title}
|
|
onChange={(e) => setEditForm({...editForm, title: e.target.value})}
|
|
className="glass-input w-full rounded-lg px-3 py-2 text-sm font-bold min-w-0"
|
|
placeholder="例如:第一集:暗巷之戰"
|
|
/>
|
|
</div>
|
|
<div className="w-full sm:w-32 shrink-0">
|
|
<label className="text-[10px] text-zinc-500 uppercase font-bold">預估時長</label>
|
|
<div className="flex relative">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={parseDuration(editForm.estimatedDuration).val}
|
|
onChange={(e) => updateDuration(e.target.value, parseDuration(editForm.estimatedDuration).unit)}
|
|
className="glass-input w-full rounded-l-lg px-2 py-2 text-sm text-center border-r-0"
|
|
/>
|
|
<div className="relative w-20">
|
|
<select
|
|
value={parseDuration(editForm.estimatedDuration).unit}
|
|
onChange={(e) => updateDuration(parseDuration(editForm.estimatedDuration).val, e.target.value)}
|
|
className="glass-input w-full rounded-r-lg pl-1 pr-6 py-2 text-xs appearance-none bg-zinc-900 border-l-0"
|
|
>
|
|
<option value="min">min</option>
|
|
<option value="sec">sec</option>
|
|
</select>
|
|
<ChevronDown className="absolute right-1 top-1/2 -translate-y-1/2 w-3 h-3 text-zinc-500 pointer-events-none" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] text-zinc-500 uppercase font-bold">摘要/內容 (中文)</label>
|
|
<textarea
|
|
value={editForm.summary}
|
|
onChange={(e) => setEditForm({...editForm, summary: e.target.value})}
|
|
className="glass-input w-full rounded-lg px-3 py-2 text-sm min-h-[80px]"
|
|
placeholder="描述這一段的主要情節..."
|
|
/>
|
|
</div>
|
|
|
|
{editMode === 'pro' && (
|
|
<div className="animate-fade-in">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<label className="text-[10px] text-purple-400 uppercase font-bold">Visual Prompt (English)</label>
|
|
<button
|
|
onClick={handleOptimizePrompt}
|
|
disabled={isOptimizing}
|
|
className="text-[10px] flex items-center gap-1 text-purple-300 hover:text-white bg-purple-500/10 px-2 py-0.5 rounded border border-purple-500/30"
|
|
>
|
|
<Sparkles className={`w-3 h-3 ${isOptimizing ? 'animate-spin' : ''}`} />
|
|
{isOptimizing ? 'AI 分析優化中...' : 'AI 分析優化'}
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
value={editForm.visualConcept}
|
|
onChange={(e) => setEditForm({...editForm, visualConcept: e.target.value})}
|
|
className="glass-input w-full rounded-lg px-3 py-2 text-xs font-mono text-purple-200 min-h-[80px] border-purple-500/30"
|
|
placeholder="Enter detailed English prompt here..."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button onClick={handleSaveEdit} className="glass-btn flex-1 bg-indigo-600/30 text-white py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 hover:bg-indigo-500/50">
|
|
<Save className="w-4 h-4" /> 儲存變更
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// DISPLAY MODE
|
|
<div className="flex flex-col h-full max-w-full">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<span className="text-xs font-bold text-zinc-500 uppercase tracking-wider">{getItemLabel()} {index + 1}</span>
|
|
<div className="flex gap-2 shrink-0">
|
|
{/* Hover Tooltip for Prompt */}
|
|
<div className="relative group">
|
|
<div className="p-1.5 text-zinc-600 hover:text-indigo-400 cursor-help rounded-lg transition-colors">
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</div>
|
|
{/* Tooltip Content */}
|
|
<div className="absolute bottom-full right-0 mb-2 w-64 p-3 bg-black/95 border border-indigo-500/30 rounded-lg text-xs text-indigo-200 shadow-xl hidden group-hover:block z-50 pointer-events-none backdrop-blur-md">
|
|
<div className="font-bold mb-1 text-white border-b border-white/10 pb-1">Visual Prompt (AI 指令)</div>
|
|
<div className="font-mono opacity-80 leading-relaxed break-words">{item.visualConcept || "Auto-generated"}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button onClick={() => handleEditClick(item)} className="p-1.5 text-zinc-600 hover:text-white hover:bg-white/10 rounded-lg transition-colors" title="編輯"><Edit2 className="w-3.5 h-3.5" /></button>
|
|
<button onClick={() => handleDelete(item.id)} className="p-1.5 text-zinc-600 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors" title="刪除"><Trash2 className="w-3.5 h-3.5" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4 overflow-hidden">
|
|
<h4 className="text-lg font-bold text-white mb-1 line-clamp-2 break-all">{item.title}</h4>
|
|
<div className="flex items-center gap-2 text-[10px] text-zinc-500 font-bold uppercase tracking-wider mb-2">
|
|
<Clock className="w-3 h-3" /> {item.estimatedDuration || '1 min'}
|
|
</div>
|
|
<p className="text-sm text-zinc-400 line-clamp-3 leading-relaxed break-all">{item.summary}</p>
|
|
</div>
|
|
|
|
<div className="mt-auto pt-2">
|
|
<button
|
|
onClick={() => onSelectEpisode(item)}
|
|
disabled={isGenerating || !!producingItemId}
|
|
className={`w-full py-3 rounded-xl font-bold transition-all flex items-center justify-center gap-2 group-hover:scale-[1.02] border ${isProducing ? 'bg-emerald-600/20 border-emerald-500/50 text-emerald-300' : 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/30 border-indigo-400/50'}`}
|
|
>
|
|
{isProducing ? (
|
|
<><Sparkles className="w-4 h-4 animate-spin" /> 分析生成中...</>
|
|
) : (
|
|
<>{isGenerating && producingItemId !== item.id ? '等待中...' : '製作此集 (Produce)'} {!isGenerating && <ChevronRight className="w-4 h-4" />}</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Icons helper
|
|
const MusicIcon = ({className}: {className?: string}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>;
|
|
const SmartphoneIcon = ({className}: {className?: string}) => <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>;
|
|
|
|
export default StructurePlanner;
|