feat:생성 이력 상세보기 모달 + YouTube Shorts 텍스트 생성
- Veo3Controller에 show 엔드포인트 추가 (시나리오/프롬프트 상세 데이터 반환) - YouTube Shorts 제목/설명/해시태그 자동 생성 (완료된 영상) - DetailModal 컴포넌트: 탭 UI (시나리오/프롬프트 | YouTube 텍스트) - 이력 테이블 행 클릭 시 상세 모달 표시 - 복사 버튼으로 YouTube 텍스트 클립보드 복사 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -256,6 +256,95 @@ public function preview(int $id): Response|RedirectResponse|JsonResponse
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 이력 상세 (시나리오, 프롬프트, YouTube 텍스트 등)
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$video = VideoGeneration::where('user_id', auth()->id())->findOrFail($id);
|
||||
|
||||
$scenario = $video->scenario ?? [];
|
||||
$scenes = $scenario['scenes'] ?? [];
|
||||
|
||||
// YouTube Shorts 텍스트 생성 (완료된 영상만)
|
||||
$youtubeText = null;
|
||||
if ($video->status === VideoGeneration::STATUS_COMPLETED) {
|
||||
$youtubeText = $this->buildYoutubeText($video, $scenario, $scenes);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $video->id,
|
||||
'keyword' => $video->keyword,
|
||||
'title' => $video->title,
|
||||
'status' => $video->status,
|
||||
'progress' => $video->progress,
|
||||
'current_step' => $video->current_step,
|
||||
'error_message' => $video->error_message,
|
||||
'cost_usd' => $video->cost_usd,
|
||||
'created_at' => $video->created_at?->toIso8601String(),
|
||||
'updated_at' => $video->updated_at?->toIso8601String(),
|
||||
'scenario' => $scenario,
|
||||
'scenes' => array_map(function ($scene) {
|
||||
return [
|
||||
'scene_number' => $scene['scene_number'] ?? null,
|
||||
'scene_type' => $scene['scene_type'] ?? null,
|
||||
'narration' => $scene['narration'] ?? '',
|
||||
'visual_prompt' => $scene['visual_prompt'] ?? '',
|
||||
'duration' => $scene['duration'] ?? 0,
|
||||
'mood' => $scene['mood'] ?? '',
|
||||
];
|
||||
}, $scenes),
|
||||
'clips_data' => $video->clips_data,
|
||||
'youtube_text' => $youtubeText,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* YouTube Shorts 제목 + 설명 텍스트 빌드
|
||||
*/
|
||||
private function buildYoutubeText(VideoGeneration $video, array $scenario, array $scenes): array
|
||||
{
|
||||
$title = $video->title ?? '';
|
||||
$keyword = $video->keyword ?? '';
|
||||
|
||||
// 해시태그 생성
|
||||
$hashtags = ['#shorts', '#쇼츠'];
|
||||
if ($keyword) {
|
||||
$hashtags[] = '#' . str_replace(' ', '', $keyword);
|
||||
}
|
||||
// 시나리오에서 추가 태그 추출
|
||||
$bgmMood = $scenario['bgm_mood'] ?? '';
|
||||
if ($bgmMood) {
|
||||
$hashtags[] = '#' . $bgmMood;
|
||||
}
|
||||
$hashtags = array_merge($hashtags, ['#건강', '#건강정보', '#헬스']);
|
||||
|
||||
// 설명란 텍스트
|
||||
$descLines = [];
|
||||
$descLines[] = $title;
|
||||
$descLines[] = '';
|
||||
|
||||
// 핵심 내용 요약 (나레이션에서 추출)
|
||||
foreach ($scenes as $scene) {
|
||||
$narration = $scene['narration'] ?? '';
|
||||
if ($narration && ($scene['scene_type'] ?? '') !== 'HOOK') {
|
||||
$descLines[] = '- ' . mb_substr($narration, 0, 60);
|
||||
}
|
||||
}
|
||||
|
||||
$descLines[] = '';
|
||||
$descLines[] = implode(' ', array_unique($hashtags));
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'description' => implode("\n", $descLines),
|
||||
'hashtags' => array_unique($hashtags),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 이력 목록
|
||||
*/
|
||||
|
||||
@@ -633,7 +633,7 @@ className="bg-indigo-600 text-white px-8 py-3 rounded-lg font-medium hover:bg-in
|
||||
// ============================================================
|
||||
// History Table
|
||||
// ============================================================
|
||||
const HistoryTable = ({ onSelect }) => {
|
||||
const HistoryTable = ({ onSelect, onDetail }) => {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checked, setChecked] = useState(new Set());
|
||||
@@ -762,8 +762,12 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((item) => (
|
||||
<tr key={item.id} className={`border-b hover:bg-gray-50 ${checked.has(item.id) ? 'bg-indigo-50' : ''}`}>
|
||||
<td className="py-2 px-3">
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`border-b hover:bg-gray-50 cursor-pointer ${checked.has(item.id) ? 'bg-indigo-50' : ''}`}
|
||||
onClick={() => onDetail(item.id)}
|
||||
>
|
||||
<td className="py-2 px-3" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(item.id)}
|
||||
@@ -783,7 +787,7 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500
|
||||
<td className="py-2 px-3 text-right text-gray-600">
|
||||
{item.cost_usd > 0 ? `$${parseFloat(item.cost_usd).toFixed(2)}` : '-'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<td className="py-2 px-3 text-center" onClick={e => e.stopPropagation()}>
|
||||
{item.status === 'completed' && (
|
||||
<a href={`/video/veo3/download/${item.id}`} className="text-indigo-600 hover:underline text-xs">
|
||||
다운로드
|
||||
@@ -794,6 +798,11 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500
|
||||
진행 확인
|
||||
</button>
|
||||
)}
|
||||
{!['completed','generating_tts','generating_clips','generating_bgm','assembling'].includes(item.status) && (
|
||||
<button onClick={() => onDetail(item.id)} className="text-gray-500 hover:underline text-xs">
|
||||
상세
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -804,6 +813,255 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Detail Modal (이력 상세보기 + YouTube 텍스트)
|
||||
// ============================================================
|
||||
const DetailModal = ({ videoId, onClose }) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [copiedField, setCopiedField] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('scenario');
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoId) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
api(`/video/veo3/detail/${videoId}`)
|
||||
.then(res => setData(res.data))
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [videoId]);
|
||||
|
||||
const copyToClipboard = (text, field) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedField(field);
|
||||
setTimeout(() => setCopiedField(''), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
pending: '대기', titles_generated: '제목 생성됨', scenario_ready: '시나리오 준비',
|
||||
generating_tts: 'TTS 생성', generating_clips: '영상 생성', generating_bgm: 'BGM 생성',
|
||||
assembling: '합성 중', completed: '완료', failed: '실패',
|
||||
};
|
||||
|
||||
if (!videoId) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
|
||||
<h3 className="text-lg font-bold text-gray-800">생성 상세 정보</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 mx-auto text-indigo-600" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/></svg>
|
||||
<p className="mt-3 text-gray-500 text-sm">데이터 로딩 중...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">키워드</div>
|
||||
<div className="font-medium text-gray-800">{data.keyword}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">상태</div>
|
||||
<div className="font-medium">{statusLabels[data.status] || data.status}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">비용</div>
|
||||
<div className="font-medium text-gray-800">{data.cost_usd > 0 ? `$${parseFloat(data.cost_usd).toFixed(2)}` : '-'}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">생성일시</div>
|
||||
<div className="font-medium text-gray-800 text-sm">{data.created_at ? new Date(data.created_at).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="mb-6 bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<div className="text-xs text-indigo-600 font-medium mb-1">제목</div>
|
||||
<div className="text-lg font-bold text-gray-800">{data.title || '-'}</div>
|
||||
</div>
|
||||
|
||||
{/* Error (if failed) */}
|
||||
{data.status === 'failed' && data.error_message && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="text-xs text-red-600 font-medium mb-1">에러 메시지</div>
|
||||
<div className="text-sm text-red-700">{data.error_message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b mb-4">
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ id: 'scenario', label: '시나리오/프롬프트' },
|
||||
...(data.youtube_text ? [{ id: 'youtube', label: 'YouTube 텍스트' }] : []),
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-indigo-600 text-indigo-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab: Scenario */}
|
||||
{activeTab === 'scenario' && data.scenes && data.scenes.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{/* BGM Mood */}
|
||||
{data.scenario?.bgm_mood && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">BGM</span>
|
||||
{data.scenario.bgm_mood}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.scenes.map((scene, i) => (
|
||||
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-indigo-600">
|
||||
#{scene.scene_number} {scene.scene_type || ''} ({scene.duration}s)
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{scene.mood}</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 font-medium mb-1">나레이션</div>
|
||||
<div className="text-sm text-gray-800 bg-yellow-50 p-2 rounded">{scene.narration || '(없음)'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 font-medium mb-1">Visual Prompt</div>
|
||||
<div className="text-xs text-gray-700 bg-blue-50 p-2 rounded font-mono whitespace-pre-wrap">{scene.visual_prompt || '(없음)'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'scenario' && (!data.scenes || data.scenes.length === 0) && (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">시나리오 데이터가 없습니다</div>
|
||||
)}
|
||||
|
||||
{/* Tab: YouTube Text */}
|
||||
{activeTab === 'youtube' && data.youtube_text && (
|
||||
<div className="space-y-6">
|
||||
{/* YouTube Title */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-bold text-gray-700">Shorts 제목</label>
|
||||
<button
|
||||
onClick={() => copyToClipboard(data.youtube_text.title, 'title')}
|
||||
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
|
||||
copiedField === 'title'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{copiedField === 'title' ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm text-gray-800 font-medium">
|
||||
{data.youtube_text.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YouTube Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-bold text-gray-700">Shorts 설명란</label>
|
||||
<button
|
||||
onClick={() => copyToClipboard(data.youtube_text.description, 'desc')}
|
||||
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
|
||||
copiedField === 'desc'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{copiedField === 'desc' ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-gray-50 border border-gray-200 rounded-lg p-3 text-sm text-gray-800 whitespace-pre-wrap font-sans">
|
||||
{data.youtube_text.description}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Hashtags */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-bold text-gray-700">해시태그</label>
|
||||
<button
|
||||
onClick={() => copyToClipboard(data.youtube_text.hashtags.join(' '), 'tags')}
|
||||
className={`text-xs px-3 py-1 rounded-full font-medium transition-all ${
|
||||
copiedField === 'tags'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{copiedField === 'tags' ? '복사됨!' : '복사'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.youtube_text.hashtags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-xs font-medium">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{data && data.status === 'completed' && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t bg-gray-50">
|
||||
<a
|
||||
href={`/video/veo3/preview/${data.id}`}
|
||||
target="_blank"
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
>
|
||||
미리보기
|
||||
</a>
|
||||
<a
|
||||
href={`/video/veo3/download/${data.id}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Main App
|
||||
// ============================================================
|
||||
@@ -815,6 +1073,7 @@ className="w-4 h-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500
|
||||
const [scenario, setScenario] = useState(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [detailId, setDetailId] = useState(null);
|
||||
|
||||
// Step 1: Generate Titles
|
||||
const handleKeywordSubmit = async (kw, trendingContext = null) => {
|
||||
@@ -940,7 +1199,10 @@ className="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<HistoryTable onSelect={handleHistorySelect} />
|
||||
<HistoryTable onSelect={handleHistorySelect} onDetail={(id) => setDetailId(id)} />
|
||||
|
||||
{/* Detail Modal */}
|
||||
{detailId && <DetailModal videoId={detailId} onClose={() => setDetailId(null)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1471,6 +1471,7 @@
|
||||
Route::get('/status/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'status'])->name('status');
|
||||
Route::get('/download/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'download'])->name('download');
|
||||
Route::get('/preview/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'preview'])->name('preview');
|
||||
Route::get('/detail/{id}', [\App\Http\Controllers\Video\Veo3Controller::class, 'show'])->name('show');
|
||||
Route::get('/history', [\App\Http\Controllers\Video\Veo3Controller::class, 'history'])->name('history');
|
||||
Route::delete('/history', [\App\Http\Controllers\Video\Veo3Controller::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user