feat:튜토리얼 이력 새로고침 버튼 + 행 클릭 스크립트 상세보기

- TutorialVideoController에 detail/{id} 엔드포인트 추가 (analysis_data 반환)
- HistoryTable에 새로고침 버튼 추가 (스피너 애니메이션)
- 행 클릭 시 스크립트 상세정보 (화면별 단계, 나레이션, 소요시간) 펼침 표시
- 상세 데이터는 캐시하여 재클릭 시 재요청 없음

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-15 17:29:27 +09:00
parent c05ebab380
commit 3c8f7c9f66
3 changed files with 188 additions and 24 deletions

View File

@@ -230,6 +230,29 @@ public function history(): JsonResponse
]);
}
/**
* 이력 상세 (스크립트/분석 데이터)
*/
public function detail(int $id): JsonResponse
{
$tutorial = TutorialVideo::where('user_id', auth()->id())
->findOrFail($id);
return response()->json([
'success' => true,
'data' => [
'id' => $tutorial->id,
'title' => $tutorial->title,
'status' => $tutorial->status,
'progress' => $tutorial->progress,
'analysis_data' => $tutorial->analysis_data,
'slides_data' => $tutorial->slides_data,
'cost_usd' => $tutorial->cost_usd,
'created_at' => $tutorial->created_at?->toIso8601String(),
],
]);
}
/**
* 이력 삭제
*/

View File

@@ -652,6 +652,76 @@ className="px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red
);
};
// ============================================================
// History Detail Panel (행 클릭 시 표시)
// ============================================================
const HistoryDetail = ({ detail, onClose }) => {
if (!detail) return null;
const analysis = detail.analysis_data || [];
const totalSteps = analysis.reduce((sum, s) => sum + (s.steps || []).length, 0);
const totalDuration = analysis.reduce((sum, s) =>
sum + (s.steps || []).reduce((d, step) => d + (step.duration || 6), 0), 6
);
return (
<tr>
<td colSpan="7" className="px-0 py-0">
<div className="bg-indigo-50 border-t border-b border-indigo-200 px-4 py-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h4 className="font-bold text-gray-800">{detail.title || '제목 없음'}</h4>
<span className="text-xs px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded-full">
{totalSteps}단계 / 예상 {totalDuration}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
{analysis.length === 0 ? (
<p className="text-sm text-gray-500">분석 데이터가 없습니다.</p>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{analysis.map((screen, i) => {
const steps = screen.steps || [];
return (
<div key={i} className="bg-white rounded-lg border border-gray-200 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-indigo-600 text-white text-xs font-bold">
{screen.screen_number || i + 1}
</span>
<span className="font-medium text-gray-800 text-sm">{screen.title || `화면 ${i + 1}`}</span>
<span className="text-xs text-gray-400">{steps.length}단계</span>
</div>
<div className="space-y-1.5 ml-3 border-l-2 border-indigo-100 pl-3">
{steps.map((step, j) => (
<div key={j} className="flex items-start gap-2">
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-bold mt-0.5 flex-shrink-0">
{step.step_number || j + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-600">
{step.focused_element?.label || `단계 ${j + 1}`}
</span>
<span className="text-[10px] text-gray-400">{step.duration || 6}</span>
</div>
<p className="text-xs text-gray-500 mt-0.5 leading-relaxed">{step.narration || '-'}</p>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</td>
</tr>
);
};
// ============================================================
// History Table
// ============================================================
@@ -659,17 +729,26 @@ className="px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red
const [items, setItems] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [expandedId, setExpandedId] = useState(null);
const [detailData, setDetailData] = useState({});
const [detailLoading, setDetailLoading] = useState(null);
const fetchHistory = async () => {
const fetchHistory = async (showSpinner) => {
if (showSpinner) setRefreshing(true);
try {
const data = await api('/video/tutorial/history');
setItems(data.data || []);
} catch (err) {
console.error(err);
} finally {
if (showSpinner) setRefreshing(false);
}
};
useEffect(() => { fetchHistory(); }, [refreshKey]);
useEffect(() => { fetchHistory(false); }, [refreshKey]);
const handleRefresh = () => { fetchHistory(true); };
const toggleSelect = (id) => {
setSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
@@ -688,7 +767,7 @@ className="px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red
body: JSON.stringify({ ids: selected }),
});
setSelected([]);
fetchHistory();
fetchHistory(false);
} catch (err) {
alert(err.message);
} finally {
@@ -696,6 +775,27 @@ className="px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red
}
};
const toggleDetail = async (id) => {
if (expandedId === id) {
setExpandedId(null);
return;
}
setExpandedId(id);
if (!detailData[id]) {
setDetailLoading(id);
try {
const data = await api(`/video/tutorial/detail/${id}`);
setDetailData(prev => ({ ...prev, [id]: data.data }));
} catch (err) {
console.error('상세 조회 실패:', err);
} finally {
setDetailLoading(null);
}
}
};
const statusLabel = (status) => {
const map = {
pending: { label: '대기', cls: 'bg-gray-100 text-gray-600' },
@@ -716,7 +816,19 @@ className="px-5 py-2.5 bg-red-600 text-white rounded-lg font-medium hover:bg-red
return (
<div className="mt-10 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-bold text-gray-800">생성 이력</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-gray-800">생성 이력</h3>
<button
onClick={handleRefresh}
disabled={refreshing}
className="p-1.5 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
title="새로고침"
>
<svg className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
{selected.length > 0 && (
<button
onClick={deleteSelected}
@@ -745,27 +857,55 @@ className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700
</thead>
<tbody className="divide-y divide-gray-100">
{items.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelect(item.id)} className="rounded" />
</td>
<td className="px-3 py-2 text-gray-800 font-medium">{item.title || `#${item.id}`}</td>
<td className="px-3 py-2 text-center">{statusLabel(item.status)}</td>
<td className="px-3 py-2 text-center text-gray-500">{item.progress}%</td>
<td className="px-3 py-2 text-center text-gray-500">${Number(item.cost_usd || 0).toFixed(3)}</td>
<td className="px-3 py-2 text-center text-gray-400 text-xs">
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
</td>
<td className="px-3 py-2 text-center">
{item.status === 'completed' && (
<div className="flex gap-1 justify-center">
<a href={`/video/tutorial/preview/${item.id}`} target="_blank" className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">보기</a>
<span className="text-gray-300">|</span>
<a href={`/video/tutorial/download/${item.id}`} className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">저장</a>
<React.Fragment key={item.id}>
<tr
className={`hover:bg-gray-50 cursor-pointer transition-colors ${expandedId === item.id ? 'bg-indigo-50' : ''}`}
onClick={(e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'A' || e.target.closest('a')) return;
toggleDetail(item.id);
}}
>
<td className="px-3 py-2">
<input type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelect(item.id)} className="rounded" />
</td>
<td className="px-3 py-2 text-gray-800 font-medium">
<div className="flex items-center gap-1.5">
<svg className={`w-3.5 h-3.5 text-gray-400 transition-transform flex-shrink-0 ${expandedId === item.id ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{item.title || `#${item.id}`}
</div>
)}
</td>
</tr>
</td>
<td className="px-3 py-2 text-center">{statusLabel(item.status)}</td>
<td className="px-3 py-2 text-center text-gray-500">{item.progress}%</td>
<td className="px-3 py-2 text-center text-gray-500">${Number(item.cost_usd || 0).toFixed(3)}</td>
<td className="px-3 py-2 text-center text-gray-400 text-xs">
{item.created_at ? new Date(item.created_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '-'}
</td>
<td className="px-3 py-2 text-center">
{item.status === 'completed' && (
<div className="flex gap-1 justify-center">
<a href={`/video/tutorial/preview/${item.id}`} target="_blank" className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">보기</a>
<span className="text-gray-300">|</span>
<a href={`/video/tutorial/download/${item.id}`} className="text-indigo-600 hover:text-indigo-800 text-xs font-medium">저장</a>
</div>
)}
</td>
</tr>
{expandedId === item.id && (
detailLoading === item.id ? (
<tr><td colSpan="7" className="px-4 py-6 text-center text-gray-500 text-sm">
<svg className="animate-spin h-5 w-5 text-indigo-600 mx-auto mb-2" 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>
스크립트 불러오는 ...
</td></tr>
) : (
<HistoryDetail
detail={detailData[item.id]}
onClose={() => setExpandedId(null)}
/>
)
)}
</React.Fragment>
))}
</tbody>
</table>

View File

@@ -1490,6 +1490,7 @@
Route::get('/download/{id}', [\App\Http\Controllers\Video\TutorialVideoController::class, 'download'])->name('download');
Route::get('/preview/{id}', [\App\Http\Controllers\Video\TutorialVideoController::class, 'preview'])->name('preview');
Route::get('/history', [\App\Http\Controllers\Video\TutorialVideoController::class, 'history'])->name('history');
Route::get('/detail/{id}', [\App\Http\Controllers\Video\TutorialVideoController::class, 'detail'])->name('detail');
Route::delete('/history', [\App\Http\Controllers\Video\TutorialVideoController::class, 'destroy'])->name('destroy');
});