Initial commit — Bradly branding editor platform

This commit is contained in:
2026-06-02 03:27:03 -05:00
commit b135a70cc7
180 changed files with 43160 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import { Download, Loader2, X, Clock, CheckCircle, AlertCircle, FileVideo, Image as ImageIcon } from 'lucide-react';
interface RenderJob {
id: string;
status: 'queued' | 'rendering' | 'done' | 'error';
progress: number;
format: string;
width: number;
height: number;
downloadUrl?: string;
error?: string;
createdAt: number;
completedAt?: number;
fileSizeBytes?: number;
}
interface RenderHistoryPanelProps {
isOpen: boolean;
onClose: () => void;
}
/**
* RenderHistoryPanel — Shows past and active render jobs with progress,
* download links, and job status information.
*/
export const RenderHistoryPanel: React.FC<RenderHistoryPanelProps> = ({ isOpen, onClose }) => {
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 && (
<a
href={job.downloadUrl}
download
title="Descargar"
className="flex items-center gap-1 px-2 py-1 bg-emerald-600/20 text-emerald-300 rounded hover:bg-emerald-600/30 transition-colors"
>
<Download size={10} />
<span>Descargar</span>
</a>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
};