studio/StructurePlanner.tsx

304 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2025-12-10 09:43:39 +00:00
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;