191 lines
7.0 KiB
TypeScript
191 lines
7.0 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { Download, Loader2, X, Clock, CheckCircle, AlertCircle, FileVideo, Image as ImageIcon, FolderOpen } from 'lucide-react';
|
||
|
||
interface RenderJob {
|
||
id: string;
|
||
status: 'queued' | 'rendering' | 'done' | 'error';
|
||
progress: number;
|
||
format: string;
|
||
width: number;
|
||
height: number;
|
||
downloadUrl?: string;
|
||
targetPath?: string;
|
||
error?: string;
|
||
createdAt: number;
|
||
completedAt?: number;
|
||
fileSizeBytes?: number;
|
||
}
|
||
|
||
interface RenderHistoryPanelProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onDownload?: (job: RenderJob) => void;
|
||
}
|
||
|
||
/**
|
||
* RenderHistoryPanel — Shows past and active render jobs with progress,
|
||
* download links, and job status information.
|
||
*/
|
||
export const RenderHistoryPanel: React.FC<RenderHistoryPanelProps> = ({ isOpen, onClose, onDownload = () => {} }) => {
|
||
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
setLoading(true);
|
||
fetch('/api/render/jobs')
|
||
.then(res => res.json())
|
||
.then(data => setJobs(data.jobs ?? []))
|
||
.catch(() => setJobs([]))
|
||
.finally(() => setLoading(false));
|
||
|
||
// SSE for real-time updates
|
||
const es = new EventSource('/api/render/events');
|
||
es.onmessage = (event) => {
|
||
try {
|
||
const { type, job } = JSON.parse(event.data);
|
||
if (type === 'job-update' && job) {
|
||
setJobs(prev => {
|
||
const idx = prev.findIndex(j => j.id === job.id);
|
||
if (idx >= 0) {
|
||
const updated = [...prev];
|
||
updated[idx] = job;
|
||
return updated;
|
||
}
|
||
return [job, ...prev];
|
||
});
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
return () => es.close();
|
||
}, [isOpen]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const formatSize = (bytes?: number) => {
|
||
if (!bytes) return '—';
|
||
if (bytes < 1024) return `${bytes}B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||
};
|
||
|
||
const formatTime = (ms: number) => {
|
||
const s = Math.floor(ms / 1000);
|
||
if (s < 60) return `${s}s`;
|
||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||
};
|
||
|
||
const statusIcon = (status: string) => {
|
||
switch (status) {
|
||
case 'queued': return <Clock size={12} className="text-amber-400" />;
|
||
case 'rendering': return <Loader2 size={12} className="text-violet-400 animate-spin" />;
|
||
case 'done': return <CheckCircle size={12} className="text-emerald-400" />;
|
||
case 'error': return <AlertCircle size={12} className="text-red-400" />;
|
||
default: return null;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center" onClick={onClose}>
|
||
<div className="bg-neutral-900 border border-neutral-700 rounded-xl shadow-2xl w-[480px] max-h-[70vh] overflow-hidden" onClick={e => e.stopPropagation()}>
|
||
{/* Header */}
|
||
<div className="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||
<FileVideo size={16} className="text-violet-400" />
|
||
Historial de Renders
|
||
</h3>
|
||
<button onClick={onClose} title="Cerrar" className="text-neutral-500 hover:text-white p-1 rounded hover:bg-neutral-800 transition-colors">
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="overflow-y-auto max-h-[60vh] custom-scrollbar p-3 space-y-2">
|
||
{loading && (
|
||
<div className="text-center py-8 text-neutral-500 text-xs">
|
||
<Loader2 size={20} className="animate-spin mx-auto mb-2" />
|
||
Cargando...
|
||
</div>
|
||
)}
|
||
|
||
{!loading && jobs.length === 0 && (
|
||
<div className="text-center py-8 text-neutral-600 text-xs">
|
||
No hay renders aún. Haz clic en "Renderizar" para comenzar.
|
||
</div>
|
||
)}
|
||
|
||
{jobs.map(job => (
|
||
<div key={job.id} className="bg-neutral-950 border border-neutral-800 rounded-lg p-3 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
{statusIcon(job.status)}
|
||
<span className="text-[10px] font-medium text-neutral-300 uppercase">
|
||
{job.format}
|
||
</span>
|
||
<span className="text-[9px] text-neutral-600 font-mono">
|
||
{job.width}×{job.height}
|
||
</span>
|
||
</div>
|
||
<span className="text-[9px] text-neutral-600 font-mono">
|
||
{new Date(job.createdAt).toLocaleTimeString()}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Progress bar */}
|
||
{job.status === 'rendering' && (
|
||
<div className="w-full h-1.5 bg-neutral-800 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-gradient-to-r from-violet-500 to-fuchsia-500 transition-all rounded-full"
|
||
style={{ width: `${job.progress}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Status info */}
|
||
<div className="flex items-center justify-between text-[9px]">
|
||
{job.status === 'rendering' && (
|
||
<span className="text-violet-400 font-mono">{job.progress}%</span>
|
||
)}
|
||
{job.status === 'done' && (
|
||
<span className="text-emerald-400">
|
||
✅ Completado en {formatTime((job.completedAt ?? 0) - job.createdAt)} — {formatSize(job.fileSizeBytes)}
|
||
</span>
|
||
)}
|
||
{job.status === 'error' && (
|
||
<span className="text-red-400">{job.error}</span>
|
||
)}
|
||
{job.status === 'queued' && (
|
||
<span className="text-amber-400">En cola...</span>
|
||
)}
|
||
|
||
{/* Download button */}
|
||
{job.status === 'done' && job.downloadUrl && (
|
||
<button
|
||
onClick={() => {
|
||
if ((window as any).electronAPI && job.targetPath) {
|
||
(window as any).electronAPI.fs.showItemInFolder(job.targetPath);
|
||
} else {
|
||
const a = document.createElement('a');
|
||
a.href = job.downloadUrl!;
|
||
a.download = `export-${job.id.slice(0, 8)}`;
|
||
a.click();
|
||
}
|
||
}}
|
||
title="Abrir en carpeta"
|
||
className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 transition-colors flex items-center gap-1.5"
|
||
>
|
||
<FolderOpen size={12} />
|
||
<span>Abrir en carpeta</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|