157 lines
7.6 KiB
TypeScript
157 lines
7.6 KiB
TypeScript
import React from 'react';
|
|
import { GeneratedAssets } from '../types';
|
|
import { Play, Clock, Music, Mic, Video, Image as ImageIcon, Download } from 'lucide-react';
|
|
|
|
/**
|
|
* ResultList 컴포넌트의 Props 정의
|
|
* @interface ResultListProps
|
|
* @property {GeneratedAssets[]} history - 생성된 에셋들의 배열 (기록)
|
|
* @property {(asset: GeneratedAssets) => void} onSelect - 목록에서 에셋 선택 시 호출될 콜백 함수
|
|
* @property {string} [currentId] - 현재 선택된 에셋의 ID (선택 사항, UI에서 강조 표시용)
|
|
*/
|
|
interface ResultListProps {
|
|
history: GeneratedAssets[];
|
|
onSelect: (asset: GeneratedAssets) => void;
|
|
currentId?: string;
|
|
}
|
|
|
|
/**
|
|
* 생성 기록 목록 컴포넌트
|
|
* 이전에 생성된 AI 광고 영상/포스터 목록을 보여주고, 클릭 시 해당 에셋을 다시 볼 수 있도록 합니다.
|
|
*/
|
|
const ResultList: React.FC<ResultListProps> = ({ history, onSelect, currentId }) => {
|
|
// 파일 강제 다운로드 핸들러
|
|
const handleDirectDownload = async (e: React.MouseEvent, url: string, filename: string) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
const blob = await response.blob();
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
|
|
window.URL.revokeObjectURL(blobUrl);
|
|
a.remove();
|
|
} catch (err) {
|
|
console.error("다운로드 실패:", err);
|
|
window.open(url, '_blank');
|
|
}
|
|
};
|
|
|
|
if (history.length === 0) return null;
|
|
|
|
return (
|
|
<div className="w-full max-w-5xl mx-auto mt-12 mb-20 animate-in slide-in-from-bottom-10 duration-700">
|
|
<div className="flex items-center gap-3 mb-6 px-4">
|
|
<Clock className="w-5 h-5 text-purple-400" />
|
|
<h3 className="text-xl font-bold text-white">생성 기록 (History)</h3>
|
|
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-300 text-xs rounded-full font-mono">{history.length}</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 px-4">
|
|
{history.map((asset) => (
|
|
<div
|
|
key={asset.id}
|
|
onClick={() => onSelect(asset)}
|
|
className={`group relative bg-white/5 border rounded-2xl overflow-hidden cursor-pointer transition-all hover:scale-[1.02] hover:shadow-xl hover:shadow-purple-500/10
|
|
${currentId === asset.id ? 'border-purple-500 ring-1 ring-purple-500 bg-white/10' : 'border-white/10 hover:border-purple-500/50'}
|
|
`}
|
|
>
|
|
{/* 포스터 썸네일 */}
|
|
<div className="aspect-video bg-black relative overflow-hidden">
|
|
{asset.posterUrl ? (
|
|
<img
|
|
src={asset.posterUrl}
|
|
alt={asset.businessName}
|
|
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity"
|
|
onError={(e) => {
|
|
e.currentTarget.style.display = 'none'; // 이미지 숨김
|
|
e.currentTarget.nextElementSibling?.classList.remove('hidden'); // 대체 div 표시 (구조상 별도 처리 필요하지만 간단히 숨김 처리)
|
|
// 부모 요소에 배경색이 있으므로 이미지가 숨겨지면 배경색이 보임.
|
|
// 더 완벽하게 하려면 state로 관리해야 하나, 여기선 간단히 처리.
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full bg-gray-800 flex items-center justify-center text-gray-500"><ImageIcon /></div>
|
|
)}
|
|
{/* 이미지 로드 실패 시 보여줄 폴백 (JS로 제어하기보다, 위 onError에서 이미지 숨기면 아래 배경이 보임) */}
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity backdrop-blur-[2px]">
|
|
<div className="p-3 rounded-full bg-white/20 backdrop-blur-sm border border-white/30">
|
|
<Play className="w-6 h-6 text-white fill-white" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="absolute top-2 left-2 flex gap-1 flex-wrap">
|
|
{asset.audioMode === 'Song' ? (
|
|
<div className="p-1 bg-purple-600/90 rounded text-white shadow-sm"><Music className="w-3 h-3" /></div>
|
|
) : (
|
|
<div className="p-1 bg-blue-600/90 rounded text-white shadow-sm"><Mic className="w-3 h-3" /></div>
|
|
)}
|
|
<div className="px-2 py-0.5 bg-black/60 backdrop-blur-sm rounded text-[10px] text-white font-bold border border-white/10 flex items-center">
|
|
{asset.textEffect}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 정보 영역 */}
|
|
<div className="p-4">
|
|
<h4 className="text-white font-bold truncate mb-1 text-base">{asset.businessName}</h4>
|
|
|
|
{asset.sourceUrl && (
|
|
<a href={asset.sourceUrl} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} className="text-xs text-blue-400 hover:underline truncate block mb-2 opacity-70 hover:opacity-100">
|
|
{asset.sourceUrl}
|
|
</a>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
|
{/* 생성 날짜 및 시간 */}
|
|
<span>
|
|
{new Date(asset.createdAt).toLocaleDateString()}
|
|
</span>
|
|
{/* 장르 또는 비디오 타입 표시 */}
|
|
<span className="flex items-center gap-1 bg-white/5 px-1.5 py-0.5 rounded">
|
|
{asset.audioMode === 'Song' ? (
|
|
<>{asset.musicGenre || 'Music'}</>
|
|
) : (
|
|
<><Mic className="w-3 h-3" /> Narration</>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 하단 버튼 영역 */}
|
|
<div className="px-4 pb-4 pt-0 flex gap-2">
|
|
{asset.finalVideoPath ? (
|
|
<button
|
|
onClick={(e) => handleDirectDownload(e, asset.finalVideoPath!, `CastAD_${asset.businessName}_Final.mp4`)}
|
|
className="flex-1 py-2 bg-green-600 hover:bg-green-500 rounded-lg text-sm font-bold text-white transition-colors flex items-center justify-center gap-2"
|
|
>
|
|
<Download className="w-4 h-4" /> 다운로드
|
|
</button>
|
|
) : null}
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onSelect(asset);
|
|
}}
|
|
className={`flex-1 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-sm font-bold text-white transition-colors flex items-center justify-center gap-2 ${asset.finalVideoPath ? 'bg-gray-700 hover:bg-gray-600' : ''}`}
|
|
>
|
|
<Play className="w-4 h-4" /> {asset.finalVideoPath ? '수정/재생성' : '결과 보기 / 다운로드'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ResultList; |