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
+386
View File
@@ -0,0 +1,386 @@
import React, { useState, useCallback, useMemo } from 'react';
import { DesignMD, TimelineElement, TimelineLayer, CompanyProfile, Project, ContentPiece, ContentPillar, ExpressTemplate } from './types';
import { TopHeader } from './components/TopHeader';
import { BrandArchitecture } from './components/BrandArchitecture';
import { Dashboard } from './components/Dashboard';
import { ProductionForm } from './components/dashboard/ProductionForm';
import { StudioEditor } from './components/studio/StudioEditor';
import { ExpressEditor } from './components/express/ExpressEditor';
import { StudioTopBar } from './components/studio/StudioTopBar';
import { EditorProvider, useEditor } from './context/EditorContext';
import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults';
import { useCustomTooltips } from './hooks/useCustomTooltips';
import { ToastProvider } from './components/ui/ToastProvider';
import { usePersistence, loadCompanies, useTemplatePersistence, loadTemplates } from './hooks/usePersistence';
import { ContentGridView } from './components/content-grid/ContentGridView';
import { TemplateBuilder } from './components/express/builder/TemplateBuilder';
import { EXPRESS_TEMPLATES } from './config/expressTemplates';
import { compileExpressToTimeline } from './utils/expressCompiler';
import { FullscreenToggle } from './components/ui/FullscreenToggle';
type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
// ── Content persistence ──
const CONTENT_STORAGE_KEY = 'remix-content-data';
function loadContentData(): Record<string, { pieces: ContentPiece[]; pillars: ContentPillar[] }> | null {
try {
const raw = localStorage.getItem(CONTENT_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function saveContentData(data: Record<string, { pieces: ContentPiece[]; pillars: ContentPillar[] }>): void {
try { localStorage.setItem(CONTENT_STORAGE_KEY, JSON.stringify(data)); } catch {}
}
export default function App() {
const [companies, setCompanies] = useState<CompanyProfile[]>(() => {
return loadCompanies() ?? PREDEFINED_COMPANIES;
});
const [currentCompanyId, setCurrentCompanyId] = useState<string | null>(null);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState<Step>('dashboard');
const [designMD, setDesignMD] = useState<DesignMD>(DEFAULT_DESIGN_MD);
const [outputFormat, setOutputFormat] = useState<'video' | 'image'>('video');
// Global templates (decoupled from brands) — persisted
const [globalTemplates, setGlobalTemplates] = useState<ExpressTemplate[]>(() => {
return loadTemplates() ?? [];
});
const [templateBuilderFormat, setTemplateBuilderFormat] = useState<'video' | 'image'>('image');
const [templateBuilderAspect, setTemplateBuilderAspect] = useState<ExpressTemplate['aspectRatio']>('9:16');
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState<ExpressTemplate | null>(null);
// Production form state
const [productionTemplate, setProductionTemplate] = useState<ExpressTemplate | null>(null);
const [productionBrand, setProductionBrand] = useState<CompanyProfile | null>(null);
// Merge preset + custom templates for the dashboard
const allTemplates = useMemo(() => [
...EXPRESS_TEMPLATES,
...globalTemplates,
], [globalTemplates]);
const handleSaveGlobalTemplate = useCallback((template: ExpressTemplate) => {
setGlobalTemplates(prev => {
const existing = prev.findIndex(t => t.id === template.id);
if (existing >= 0) {
const next = [...prev];
next[existing] = template;
return next;
}
return [...prev, template];
});
setEditingGlobalTemplate(null);
setCurrentStep('dashboard');
}, []);
// Content grid state (per company)
const [contentData, setContentData] = useState<Record<string, { pieces: ContentPiece[]; pillars: ContentPillar[] }>>(() => {
return loadContentData() ?? {};
});
const getContentForCompany = useCallback((companyId: string) => {
return contentData[companyId] ?? { pieces: [], pillars: [...DEFAULT_PILLARS] };
}, [contentData]);
const updateContentPieces = useCallback((companyId: string, pieces: ContentPiece[]) => {
setContentData(prev => {
const next = { ...prev, [companyId]: { ...prev[companyId] ?? { pillars: [...DEFAULT_PILLARS] }, pieces } };
saveContentData(next);
return next;
});
}, []);
const updateContentPillars = useCallback((companyId: string, pillars: ContentPillar[]) => {
setContentData(prev => {
const next = { ...prev, [companyId]: { ...prev[companyId] ?? { pieces: [] }, pillars } };
saveContentData(next);
return next;
});
}, []);
// Studio initial data (passed to EditorProvider when entering studio)
const [studioInitialElements, setStudioInitialElements] = useState<TimelineElement[]>([]);
const [studioInitialLayers, setStudioInitialLayers] = useState<TimelineLayer[]>([
{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' }
]);
// Key to force remount EditorProvider when switching projects
const [editorKey, setEditorKey] = useState(0);
useCustomTooltips();
usePersistence(companies);
useTemplatePersistence(globalTemplates);
const handleDesignChange = (key: keyof DesignMD, value: string | number | string[] | boolean) => {
setDesignMD((prev) => {
const newDesign = { ...prev, [key]: value };
if (currentCompanyId) {
setCompanies(prev2 => prev2.map(c => c.id === currentCompanyId ? { ...c, design: newDesign } : c));
}
return newDesign;
});
};
const saveCurrentProject = (elements: TimelineElement[], layers: TimelineLayer[]) => {
if (currentCompanyId) {
setCompanies(prev => prev.map(c => {
if (c.id !== currentCompanyId) return c;
const projs = c.projects || [];
if (currentProjectId) {
return {
...c,
projects: projs.map(p => p.id === currentProjectId ? { ...p, elements, layers } : p)
};
} else {
const newId = `proj-${Date.now()}`;
const newProject: Project = {
id: newId,
name: `Proyecto ${outputFormat === 'video' ? 'Video' : 'Imagen'} ${projs.length + 1}`,
format: outputFormat,
elements,
layers
};
setCurrentProjectId(newId);
return { ...c, projects: [...projs, newProject] };
}
}));
}
};
const enterStudio = (design: DesignMD, format: 'video' | 'image', elements: TimelineElement[], layers: TimelineLayer[], companyId?: string, projectId?: string | null) => {
if (companyId) setCurrentCompanyId(companyId);
if (projectId !== undefined) setCurrentProjectId(projectId);
setDesignMD(design);
setOutputFormat(format);
setStudioInitialElements(elements);
setStudioInitialLayers(layers);
setEditorKey(prev => prev + 1);
setCurrentStep('studio');
};
// ── Blank canvas editors (no brand) ──
const handleStartExpressBlank = useCallback(() => {
setCurrentCompanyId(null);
setDesignMD(DEFAULT_DESIGN_MD);
setOutputFormat('video');
setCurrentStep('express');
}, []);
const handleStartProBlank = useCallback(() => {
const initialElements: TimelineElement[] = [{
id: `el-content-${Date.now()}`,
layerId: 'layer-1',
type: 'text',
content: 'Inserta tu contenido aquí',
startFrame: 0,
endFrame: 180,
x: 50, y: 50,
fontSize: 48,
color: '#FFFFFF',
fontFamily: DEFAULT_DESIGN_MD.baseFont,
}];
const initialLayers: TimelineLayer[] = [{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' }];
enterStudio(DEFAULT_DESIGN_MD, 'video', initialElements, initialLayers, undefined, null);
}, []);
// ── Production flow: template × brand → form → editor ──
const handleGenerate = useCallback((template: ExpressTemplate, brand: CompanyProfile) => {
setProductionTemplate(template);
setProductionBrand(brand);
setCurrentStep('production-form');
}, []);
// ── Template management (edit / duplicate / delete) ──
const handleEditTemplate = useCallback((template: ExpressTemplate) => {
setEditingGlobalTemplate(template);
setTemplateBuilderFormat(template.format);
setCurrentStep('template-builder');
}, []);
const handleDuplicateTemplate = useCallback((template: ExpressTemplate) => {
const copy: ExpressTemplate = {
...template,
id: `tpl-${Date.now()}`,
name: `${template.name} (Copia)`,
isCustom: true,
createdAt: new Date().toISOString(),
scenes: template.scenes.map(s => ({ ...s, id: `scene-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` })),
};
setGlobalTemplates(prev => [...prev, copy]);
}, []);
const handleDeleteTemplate = useCallback((id: string) => {
setGlobalTemplates(prev => prev.filter(t => t.id !== id));
}, []);
const handleProducePro = useCallback((fieldData: Record<string, string>) => {
if (!productionTemplate || !productionBrand) return;
// Compile template + brand + fieldData → TimelineElement[]
const compiled = compileExpressToTimeline(productionTemplate, fieldData, productionBrand.design, productionBrand);
enterStudio(productionBrand.design, productionTemplate.format, compiled.elements, compiled.layers, productionBrand.id, null);
}, [productionTemplate, productionBrand]);
return (
<ToastProvider>
<div className="flex flex-col h-screen bg-neutral-950 text-neutral-100 font-sans overflow-hidden">
{currentStep !== 'studio' && (
<TopHeader
currentStep={currentStep}
setCurrentStep={(step) => {
setCurrentStep(step);
}}
outputFormat={outputFormat}
onStartExpressBlank={handleStartExpressBlank}
onStartProBlank={handleStartProBlank}
/>
)}
<div className="flex-1 flex overflow-hidden relative bg-neutral-950">
{currentStep === 'dashboard' && (
<Dashboard
companies={companies}
templates={allTemplates}
onCreateBrand={(name, industry) => {
const newAppId = Date.now().toString();
const newBrand: CompanyProfile = {
id: newAppId,
name,
industry,
design: { ...DEFAULT_DESIGN_MD },
projects: []
};
setCompanies(prev => [...prev, newBrand]);
setCurrentCompanyId(newAppId);
setDesignMD(newBrand.design);
setCurrentStep('brand');
}}
onDeleteBrand={(id) => {
setCompanies(prev => prev.filter(c => c.id !== id));
}}
onDuplicateBrand={(id) => {
const original = companies.find(c => c.id === id);
if (!original) return;
const newId = Date.now().toString();
const duplicate: CompanyProfile = {
...original,
id: newId,
name: `${original.name} (Copia)`,
projects: [],
design: { ...original.design, brandStickers: [...(original.design.brandStickers || [])] },
socialLinks: original.socialLinks ? { ...original.socialLinks } : undefined,
};
setCompanies(prev => [...prev, duplicate]);
}}
onEditBrand={(design) => {
const comp = companies.find(c => c.design === design);
if (comp) setCurrentCompanyId(comp.id);
setDesignMD(design);
setCurrentStep('brand');
}}
onOpenContentGrid={(companyId) => {
setCurrentCompanyId(companyId);
setCurrentStep('content-grid');
}}
onCreateTemplate={(format, aspect) => {
setTemplateBuilderFormat(format);
setTemplateBuilderAspect(aspect);
setEditingGlobalTemplate(null);
setCurrentStep('template-builder');
}}
onEditTemplate={handleEditTemplate}
onDuplicateTemplate={handleDuplicateTemplate}
onDeleteTemplate={handleDeleteTemplate}
onGenerate={handleGenerate}
/>
)}
{currentStep === 'production-form' && productionTemplate && productionBrand && (
<ProductionForm
template={productionTemplate}
brand={productionBrand}
onBack={() => setCurrentStep('dashboard')}
onProducePro={handleProducePro}
/>
)}
{currentStep === 'brand' && (
<BrandArchitecture
company={companies.find(c => c.id === currentCompanyId)!}
handleCompanyChange={(company) => {
setCompanies(prev => prev.map(c => c.id === company.id ? company : c));
}}
designMD={designMD}
handleDesignChange={handleDesignChange}
onContinue={() => setCurrentStep('dashboard')}
/>
)}
{currentStep === 'content-grid' && currentCompanyId && (
<ContentGridView
company={companies.find(c => c.id === currentCompanyId)!}
pieces={getContentForCompany(currentCompanyId).pieces}
pillars={getContentForCompany(currentCompanyId).pillars}
onPiecesChange={(pieces) => updateContentPieces(currentCompanyId, pieces)}
onPillarsChange={(pillars) => updateContentPillars(currentCompanyId, pillars)}
onOpenProject={(projectId) => {
const comp = companies.find(c => c.id === currentCompanyId);
if (comp) {
const proj = comp.projects.find(p => p.id === projectId);
if (proj) {
const layers = proj.layers.length > 0 ? proj.layers : [{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' as const }];
enterStudio(comp.design, proj.format, proj.elements, layers, comp.id, projectId);
}
}
}}
/>
)}
{currentStep === 'express' && (
<ExpressEditor
designMD={designMD}
company={companies.find(c => c.id === currentCompanyId)}
onBack={() => setCurrentStep('dashboard')}
onUpgradeToPro={(elements, layers) => {
const comp = companies.find(c => c.id === currentCompanyId);
enterStudio(designMD, outputFormat, elements, layers, comp?.id, null);
}}
onExport={(elements, layers, format) => {
const comp = companies.find(c => c.id === currentCompanyId);
enterStudio(designMD, format, elements, layers, comp?.id, null);
}}
/>
)}
{currentStep === 'studio' && (
<EditorProvider
key={editorKey}
initialDesignMD={designMD}
initialElements={studioInitialElements}
initialLayers={studioInitialLayers}
initialFormat={outputFormat}
brandContent={companies.find(c => c.id === currentCompanyId)?.brandContent}
>
<div className="flex-1 flex flex-col overflow-hidden">
<StudioTopBar setCurrentStep={setCurrentStep} />
<StudioEditor />
</div>
</EditorProvider>
)}
{currentStep === 'template-builder' && (
<TemplateBuilder
availableBrands={companies}
onSave={handleSaveGlobalTemplate}
onBack={() => setCurrentStep('dashboard')}
editingTemplate={editingGlobalTemplate}
initialFormat={templateBuilderFormat}
initialAspect={templateBuilderAspect}
/>
)}
</div>
<FullscreenToggle />
</div>
</ToastProvider>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Remotion Root — Entry point for bundler to discover compositions.
*
* This file is referenced by the server-side renderer bundle step.
* It registers BrandComposition as a renderable Composition.
*/
import React from 'react';
import { Composition, Still, registerRoot } from 'remotion';
import { BrandComposition } from './components/BrandComposition';
import { RenderProps } from './types';
export const RemotionRoot: React.FC = () => {
return (
<>
{/* Video composition — used for MP4/WebM rendering */}
<Composition
id="BrandVideo"
component={BrandComposition}
durationInFrames={150}
fps={30}
width={1080}
height={1080}
defaultProps={{
designMD: {} as any,
textOverlay: '',
timelineElements: [],
layers: [],
}}
/>
{/* Still composition — used for PNG/JPEG rendering */}
<Still
id="BrandStill"
component={BrandComposition}
width={1080}
height={1080}
defaultProps={{
designMD: {} as any,
textOverlay: '',
timelineElements: [],
layers: [],
}}
/>
</>
);
};
registerRoot(RemotionRoot);
+229
View File
@@ -0,0 +1,229 @@
import React, { useState, useCallback } from 'react';
import { Save, AlertCircle, Crown } from 'lucide-react';
import { DesignMD, CompanyProfile } from '../types';
import { BrandTabGeneral } from './brand/BrandTabGeneral';
import { BrandTabVisual } from './brand/BrandTabVisual';
import { BrandTabTypography } from './brand/BrandTabTypography';
import { BrandTabMedia } from './brand/BrandTabMedia';
import { BrandPreview } from './brand/BrandPreview';
import { Toast } from './ui/Toast';
interface BrandArchitectureProps {
company: CompanyProfile;
handleCompanyChange: (company: CompanyProfile) => void;
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
onContinue: () => void;
}
const TABS = [
{ id: 'general', label: 'Información', icon: '📋' },
{ id: 'visual', label: 'Visual y Colores', icon: '🎨' },
{ id: 'typography', label: 'Tipografía', icon: '🔤' },
{ id: 'media', label: 'Video y Audio', icon: '🎬' },
] as const;
type TabId = typeof TABS[number]['id'];
export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, handleCompanyChange, designMD, handleDesignChange, onContinue }) => {
const [zoom, setZoom] = useState(1);
const [aspectRatio, setAspectRatio] = useState<'16:9'|'1:1'|'9:16'>('9:16');
const [activeTab, setActiveTab] = useState<TabId>('general');
const [showToast, setShowToast] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const validate = useCallback((): string[] => {
const errors: string[] = [];
if (!company?.name || company.name.trim().length < 2) {
errors.push('El nombre de la marca es requerido (mín. 2 caracteres)');
}
if (!designMD.logoUrl || designMD.logoUrl.trim().length === 0) {
errors.push('El logo de la marca es requerido');
}
return errors;
}, [company, designMD]);
const handleSave = () => {
const errors = validate();
if (errors.length > 0) {
setValidationErrors(errors);
setTimeout(() => setValidationErrors([]), 5000);
return;
}
setValidationErrors([]);
setShowToast(true);
setTimeout(() => {
onContinue();
}, 800);
};
return (
<div className="flex-1 flex flex-col w-full overflow-hidden">
{/* ═══ Sticky Header: Title + Brand Identity ═══ */}
<div className="shrink-0 sticky top-0 z-20 bg-neutral-950/95 backdrop-blur-md border-b border-neutral-800/60">
<div className="px-8 pt-6 pb-4">
<div className="flex items-start justify-between gap-6">
{/* Left: Title + Description */}
<div className="min-w-0">
<h2 className="text-xl font-bold text-white tracking-tight">Reglas de tu Marca (Design MD)</h2>
<p className="text-sm text-neutral-400 leading-relaxed mt-1">
Establece el plano arquitectónico visual de la empresa. Todos los videos y renders
futuros adoptarán estrictamente estos parámetros sin intervención de IA.
</p>
</div>
{/* Right: Brand Identity Card + Save */}
<div className="shrink-0 flex items-center gap-3">
{/* Brand Identity Card */}
<div className="flex items-center gap-3 bg-neutral-900/80 border border-neutral-800 rounded-xl px-4 py-2.5 backdrop-blur-sm">
{/* Logo / Avatar */}
<div className="w-9 h-9 rounded-lg overflow-hidden bg-neutral-800 border border-neutral-700 flex items-center justify-center shrink-0">
{designMD.logoUrl ? (
<img
src={designMD.logoUrl}
alt={company.name}
className="w-full h-full object-contain"
/>
) : (
<span className="text-lg font-bold text-neutral-500">
{company.name?.charAt(0)?.toUpperCase() || '?'}
</span>
)}
</div>
{/* Name + Plan */}
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate max-w-[140px]">
{company.name || 'Sin nombre'}
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Crown size={10} className="text-amber-400 shrink-0" />
<span className="text-[10px] font-medium text-amber-400/80 tracking-wide uppercase">
{company.industry || 'Marca'}
</span>
</div>
</div>
{/* Brand color dot indicator */}
<div className="flex flex-col gap-1 ml-2">
<div
className="w-3 h-3 rounded-full border border-white/10 shadow-sm"
style={{ backgroundColor: designMD.primaryColor }}
title={`Primario: ${designMD.primaryColor}`}
/>
<div
className="w-3 h-3 rounded-full border border-white/10 shadow-sm"
style={{ backgroundColor: designMD.secondaryColor }}
title={`Secundario: ${designMD.secondaryColor}`}
/>
</div>
</div>
{/* Save Button */}
<button
onClick={handleSave}
title="Guardar marca"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white text-sm font-semibold transition-all shadow-lg shadow-emerald-900/30"
>
<Save size={14} />
Guardar
</button>
</div>
</div>
</div>
{/* ═══ Full-Width Tabbar ═══ */}
<div className="px-8 pb-0">
<div className="flex bg-neutral-900/60 border border-neutral-800 rounded-t-xl overflow-hidden">
{TABS.map((tab, idx) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-3 text-[13px] font-medium transition-all relative ${
isActive
? 'bg-neutral-800/80 text-white'
: 'text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800/30'
} ${idx > 0 ? 'border-l border-neutral-800/50' : ''}`}
>
<span className="text-sm">{tab.icon}</span>
<span className="hidden xl:inline">{tab.label}</span>
<span className="xl:hidden text-xs">{tab.label.split(' ')[0]}</span>
{/* Active indicator bar */}
{isActive && (
<div className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500" />
)}
</button>
);
})}
</div>
</div>
</div>
{/* ═══ Main Content: Form + Preview Split ═══ */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Form Column */}
<div className="w-1/2 overflow-y-auto border-r border-neutral-800 bg-neutral-950/80 backdrop-blur-sm">
<div className="max-w-xl mx-auto p-8 space-y-6">
{/* Validation Errors */}
{validationErrors.length > 0 && (
<div className="bg-rose-950/30 border border-rose-800/50 rounded-xl p-4 space-y-1.5">
{validationErrors.map((err, i) => (
<p key={i} className="text-sm text-rose-300 flex items-center gap-2">
<AlertCircle size={14} className="shrink-0" />
{err}
</p>
))}
</div>
)}
{/* Tab Content */}
{activeTab === 'general' && (
<BrandTabGeneral company={company} handleCompanyChange={handleCompanyChange} />
)}
{activeTab === 'visual' && (
<BrandTabVisual
designMD={designMD}
handleDesignChange={handleDesignChange}
/>
)}
{activeTab === 'typography' && (
<BrandTabTypography designMD={designMD} handleDesignChange={handleDesignChange} />
)}
{activeTab === 'media' && (
<BrandTabMedia
designMD={designMD}
handleDesignChange={handleDesignChange}
/>
)}
</div>
</div>
{/* Preview Column */}
<BrandPreview
designMD={designMD}
company={company}
activeTab={activeTab}
zoom={zoom}
setZoom={setZoom}
aspectRatio={aspectRatio}
setAspectRatio={setAspectRatio}
handleDesignChange={handleDesignChange}
/>
</div>
{/* Success Toast */}
{showToast && (
<Toast
message="Marca guardada exitosamente ✓"
type="success"
onDismiss={() => setShowToast(false)}
/>
)}
</div>
);
};
+112
View File
@@ -0,0 +1,112 @@
import React from 'react';
import { AbsoluteFill, useCurrentFrame } from 'remotion';
import { RenderProps } from '../types';
import { useCanvasDrag } from './composition/useCanvasDrag';
import { BackgroundLayer } from './composition/BackgroundLayer';
import { BrandOverlay } from './composition/BrandOverlay';
import { CompositionElement } from './composition/CompositionElement';
import { SmartGuides } from './composition/SmartGuides';
export const BrandComposition: React.FC<RenderProps> = ({
designMD,
textOverlay,
timelineElements = [],
layers = [],
onElementClick,
onElementPositionChange,
onElementContextMenu,
onElementDoubleClick,
onElementTransformChange,
onElementDuplicate,
onElementDelete,
onElementLock,
selectedElementId,
activeLayerId,
activeAction,
brandVisibility,
outputFormat
}) => {
const frame = useCurrentFrame();
const {
containerRef,
dragState,
setDragState,
transformDragState,
setTransformDragState,
tempPositions,
guides
} = useCanvasDrag(timelineElements, onElementPositionChange, onElementTransformChange);
// Separate brand fullscreen videos from other elements for correct z-order
const brandFullscreenEls = timelineElements.filter(el =>
el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video'
);
const otherElements = timelineElements.filter(el =>
!(el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video')
);
const renderElement = (el: typeof timelineElements[0]) => {
let layer = layers.find(l => l.id === el.layerId);
// Solo-aware mute: if any audio layer has isSolo, mute all other audio layers
if (layer?.type === 'audio') {
const anySoloActive = layers.some(l => l.type === 'audio' && l.isSolo);
if (anySoloActive && !layer.isSolo) {
layer = { ...layer, isMuted: true };
}
}
return (
<CompositionElement
key={el.id}
element={el}
layer={layer}
designMD={designMD}
frame={frame}
selectedElementId={selectedElementId ?? null}
activeLayerId={activeLayerId ?? null}
activeAction={activeAction ?? 'move'}
isImageMode={outputFormat === 'image'}
tempPositions={tempPositions}
dragStateId={dragState?.id ?? null}
containerRef={containerRef}
onElementClick={onElementClick}
onElementDoubleClick={onElementDoubleClick}
onElementContextMenu={onElementContextMenu}
onElementDuplicate={onElementDuplicate}
onElementDelete={onElementDelete}
onElementLock={onElementLock}
onDragStart={(id, startX, startY, initialElX, initialElY) => {
if (onElementPositionChange) {
setDragState({ id, startX, startY, initialElX, initialElY });
}
}}
onTransformStart={(id, type, startX, startY, initialScale, initialRot, centerX, centerY) => {
setTransformDragState({ id, type, startX, startY, initialScale, initialRot, centerX, centerY });
}}
/>
);
};
const showBackground = brandVisibility?.background ?? true;
return (
<AbsoluteFill style={{ backgroundColor: showBackground ? designMD.secondaryColor : 'transparent' }} ref={containerRef}>
{/* Layer 1: Background media (user-uploaded backgrounds) */}
<BackgroundLayer timelineElements={timelineElements} layers={layers} />
{/* Layer 2: Brand fullscreen videos (intro/outro) — BELOW logo/frame */}
{brandFullscreenEls.map(renderElement)}
{/* Layer 3: Brand Overlay (logo + frame) */}
<BrandOverlay designMD={designMD} textOverlay={textOverlay} brandVisibility={brandVisibility} />
{/* Layer 4: All other elements (text, images, non-fullscreen brand, etc.) */}
{otherElements.map(renderElement)}
{/* Smart Guides Overlay */}
<SmartGuides guides={guides} />
</AbsoluteFill>
);
};
+197
View File
@@ -0,0 +1,197 @@
import React, { useState, useCallback } from 'react';
import {
DndContext,
DragOverlay,
DragStartEvent,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { Sparkles } from 'lucide-react';
import { DesignMD, CompanyProfile, ExpressTemplate } from '../types';
import { TemplatesPanel, TemplateDragPreview } from './dashboard/TemplatesPanel';
import { BrandsPanel, BrandDragPreview } from './dashboard/BrandsPanel';
import { GenerateZone } from './dashboard/GenerateZone';
import { CreateBrandModal } from './brand/CreateBrandModal';
interface DashboardProps {
companies: CompanyProfile[];
templates: ExpressTemplate[];
onCreateBrand: (name: string, industry?: string) => void;
onDeleteBrand: (id: string) => void;
onDuplicateBrand: (id: string) => void;
onEditBrand: (design: DesignMD) => void;
onOpenContentGrid: (companyId: string) => void;
onCreateTemplate: (format: 'video' | 'image', aspect: ExpressTemplate['aspectRatio']) => void;
onEditTemplate: (template: ExpressTemplate) => void;
onDuplicateTemplate: (template: ExpressTemplate) => void;
onDeleteTemplate: (id: string) => void;
onGenerate: (template: ExpressTemplate, brand: CompanyProfile) => void;
}
type DragItem =
| { type: 'template'; template: ExpressTemplate }
| { type: 'brand'; company: CompanyProfile };
/**
* Dashboard — Redesigned around "content = template × brand".
*
* Three zones:
* 1. TemplatesPanel (top-left) — draggable template grid with search
* 2. BrandsPanel (top-right) — draggable brand folder grid with search
* 3. GenerateZone (bottom, full-width) — drop slots + Generate button
*/
export const Dashboard: React.FC<DashboardProps> = ({
companies,
templates,
onCreateBrand,
onDeleteBrand,
onDuplicateBrand,
onEditBrand,
onOpenContentGrid,
onCreateTemplate,
onEditTemplate,
onDuplicateTemplate,
onDeleteTemplate,
onGenerate,
}) => {
const [selectedTemplate, setSelectedTemplate] = useState<ExpressTemplate | null>(null);
const [selectedBrand, setSelectedBrand] = useState<CompanyProfile | null>(null);
const [activeDrag, setActiveDrag] = useState<DragItem | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
// DnD sensor config — require 5px movement before starting drag (allows click)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as DragItem | undefined;
if (data) setActiveDrag(data);
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
setActiveDrag(null);
const { active, over } = event;
if (!over) return;
const data = active.data.current as DragItem | undefined;
if (!data) return;
const droppedOnSlot = over.id as string;
if (data.type === 'template' && droppedOnSlot === 'slot-template') {
setSelectedTemplate(data.template);
} else if (data.type === 'brand' && droppedOnSlot === 'slot-brand') {
setSelectedBrand(data.company);
}
// If user drops template on brand slot or vice versa, ignore silently
}, []);
const handleDragCancel = useCallback(() => {
setActiveDrag(null);
}, []);
// Click-based selection (alternative to drag)
const handleSelectTemplate = useCallback((t: ExpressTemplate) => {
setSelectedTemplate(t);
}, []);
const handleSelectBrand = useCallback((c: CompanyProfile) => {
setSelectedBrand(c);
}, []);
const handleGenerate = useCallback(() => {
if (selectedTemplate && selectedBrand) {
onGenerate(selectedTemplate, selectedBrand);
}
}, [selectedTemplate, selectedBrand, onGenerate]);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex-1 overflow-y-auto w-full relative bg-neutral-950">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
/>
<div className="max-w-6xl w-full mx-auto p-8 relative z-10">
{/* ── Header ── */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-violet-900/30">
<Sparkles size={20} className="text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Crear Contenido</h1>
<p className="text-sm text-neutral-500">Combina una plantilla con una marca para generar contenido</p>
</div>
</div>
</div>
{/* ── Zone 1 & 2: Templates + Brands (side by side) ── */}
<div className="flex gap-5 mb-6" style={{ height: 380 }}>
<TemplatesPanel
templates={templates}
onSelect={handleSelectTemplate}
onCreateTemplate={onCreateTemplate}
onEditTemplate={onEditTemplate}
onDuplicateTemplate={onDuplicateTemplate}
onDeleteTemplate={onDeleteTemplate}
/>
<BrandsPanel
companies={companies}
onSelect={handleSelectBrand}
onCreateBrand={() => setShowCreateModal(true)}
onEditBrand={(c) => onEditBrand(c.design)}
onDeleteBrand={onDeleteBrand}
onDuplicateBrand={onDuplicateBrand}
onOpenContentGrid={onOpenContentGrid}
/>
</div>
{/* ── Zone 3: Generate Content ── */}
<GenerateZone
selectedTemplate={selectedTemplate}
selectedBrand={selectedBrand}
onClearTemplate={() => setSelectedTemplate(null)}
onClearBrand={() => setSelectedBrand(null)}
onClickTemplateSlot={() => {/* Could open a modal selector — for now click on panel */}}
onClickBrandSlot={() => {/* Could open a modal selector — for now click on panel */}}
onGenerate={handleGenerate}
/>
</div>
</div>
{/* Drag Overlay — shows a floating preview while dragging */}
<DragOverlay dropAnimation={null}>
{activeDrag?.type === 'template' && (
<TemplateDragPreview template={activeDrag.template} />
)}
{activeDrag?.type === 'brand' && (
<BrandDragPreview company={activeDrag.company} />
)}
</DragOverlay>
{/* Create Brand Modal */}
{showCreateModal && (
<CreateBrandModal
onConfirm={(name, industry) => {
onCreateBrand(name, industry);
setShowCreateModal(false);
}}
onCancel={() => setShowCreateModal(false)}
/>
)}
</DndContext>
);
};
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
interface DeleteConfirmModalProps {
onConfirm: () => void;
onCancel: () => void;
}
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({ onConfirm, onCancel }) => {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-neutral-900 border border-neutral-700 p-6 rounded-xl shadow-2xl max-w-sm w-full mx-4">
<h3 className="text-lg font-bold text-white mb-2">Eliminar Objeto</h3>
<p className="text-neutral-400 text-sm mb-6">¿Estás seguro de que deseas eliminar este elemento? Esta acción no se puede deshacer.</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-neutral-300 hover:text-white hover:bg-neutral-800 transition-colors text-sm font-medium"
>
Cancelar
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-600/90 hover:bg-red-500 text-white rounded-lg transition-colors text-sm font-medium shadow-lg shadow-red-900/20"
>
, eliminar
</button>
</div>
</div>
</div>
);
};
+159
View File
@@ -0,0 +1,159 @@
import React, { useState, useCallback } from 'react';
import { DesignMD, BrandContentPiece } from '../types';
import { X, Search, Image as ImageIcon, Video, Film, Loader2 } from 'lucide-react';
import { uploadMedia } from '../utils/mediaUploader';
import { FileDropZone } from './ui/FileDropZone';
import { StockMediaTab } from './panels/StockMediaTab';
const mockImages: string[] = [];
const mockVideos: string[] = [];
interface MediaLibraryPanelProps {
onClose: () => void;
designMD: DesignMD;
brandContent?: BrandContentPiece[];
}
type MediaTab = 'images' | 'video' | 'stock';
type LocalFile = { type: MediaTab; src: string };
export const MediaLibraryPanel: React.FC<MediaLibraryPanelProps> = ({ onClose, designMD, brandContent = [] }) => {
const [tab, setTab] = useState<MediaTab>('images');
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
const [isUploading, setIsUploading] = useState(false);
const handleFileUpload = useCallback(async (files: File[]) => {
setIsUploading(true);
try {
const newFiles: LocalFile[] = [];
for (const file of files) {
let type: MediaTab = 'images';
if (file.type.startsWith('video/')) type = 'video';
const result = await uploadMedia(file);
newFiles.push({ type, src: result.url });
}
setLocalFiles(prev => [...newFiles, ...prev]);
if (newFiles.length > 0) {
setTab(newFiles[0].type);
}
} catch (err) {
console.error('Upload failed:', err);
} finally {
setIsUploading(false);
}
}, []);
const currentItems = tab === 'images' ? mockImages : mockVideos;
const filteredLocalFiles = localFiles.filter(f => f.type === tab).map(f => f.src);
const displayItems = [...filteredLocalFiles, ...currentItems];
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Film size={14} className="text-sky-400" />
Media
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
{/* Tabs: Fotos | Video */}
<div className="flex border-b border-neutral-800">
<button
onClick={() => setTab('images')}
title="Ver Imágenes"
className={`flex-1 p-2.5 text-xs font-medium flex justify-center items-center gap-1.5 ${tab === 'images' ? 'text-violet-400 border-b-2 border-violet-400' : 'text-neutral-400 hover:text-neutral-200'}`}
>
<ImageIcon size={14} /> Fotos
</button>
<button
onClick={() => setTab('video')}
title="Ver Videos"
className={`flex-1 p-2.5 text-xs font-medium flex justify-center items-center gap-1.5 ${tab === 'video' ? 'text-violet-400 border-b-2 border-violet-400' : 'text-neutral-400 hover:text-neutral-200'}`}
>
<Video size={14} /> Video
</button>
<button
onClick={() => setTab('stock')}
title="Buscar en Pexels"
className={`flex-1 p-2.5 text-xs font-medium flex justify-center items-center gap-1.5 ${tab === 'stock' ? 'text-violet-400 border-b-2 border-violet-400' : 'text-neutral-400 hover:text-neutral-200'}`}
>
<Search size={14} /> Stock
</button>
</div>
{/* Stock Media Tab */}
{tab === 'stock' ? (
<StockMediaTab />
) : (
<div className="p-3 flex-1 overflow-y-auto space-y-3">
{/* Search */}
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
placeholder={`Buscar ${tab === 'images' ? 'fotos' : 'videos'}...`}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-violet-500"
/>
</div>
{/* Upload */}
<FileDropZone
accept={tab === 'images' ? 'image/*' : 'video/*'}
multiple
onFiles={handleFileUpload}
label={isUploading ? 'Subiendo...' : `Subir ${tab === 'images' ? 'imágenes' : 'videos'}`}
sublabel={isUploading ? undefined : "o arrastra archivos aquí"}
/>
{isUploading && (
<div className="flex items-center justify-center gap-2 py-2 text-violet-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-[10px] font-medium">Subiendo al servidor...</span>
</div>
)}
{/* Grid */}
<div className="grid grid-cols-2 gap-2">
{displayItems.map((src, i) => (
<div
key={`${tab}-${i}`}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-1"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: tab, src }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
{tab === 'images' ? (
<img src={src} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 rounded" alt="Media" draggable={false} />
) : (
<video src={src} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 rounded" />
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none rounded-lg">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
</div>
</div>
))}
</div>
{/* Empty state */}
{displayItems.length === 0 && (
<div className="text-center py-6 text-neutral-500">
{tab === 'images' ? <ImageIcon size={28} className="mx-auto mb-2 opacity-40" /> : <Video size={28} className="mx-auto mb-2 opacity-40" />}
<p className="text-xs font-medium">Sin {tab === 'images' ? 'imágenes' : 'videos'}</p>
<p className="text-[10px] mt-1">Sube archivos para empezar</p>
</div>
)}
</div>
)}
</div>
);
};
+152
View File
@@ -0,0 +1,152 @@
import React, { RefObject } from 'react';
import { PlayerRef } from '@remotion/player';
import { Type, Image as ImageIcon, Trash2, Film, Upload, Wand2, Play, ImagePlus, Square, Plus } from 'lucide-react';
import { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types';
import { AudioLayerPanel } from './properties/AudioLayerPanel';
import { GraphicLayerPanel } from './properties/GraphicLayerPanel';
import { TransitionsPanel } from './properties/TransitionsPanel';
import { GlobalSettingsPanel } from './properties/GlobalSettingsPanel';
import { ElementPropertiesPanel } from './properties/ElementPropertiesPanel';
import { ImageLayersPanel } from './properties/ImageLayersPanel';
import { MultiSelectActions } from './properties/MultiSelectActions';
import { uploadMedia } from '../utils/mediaUploader';
interface StudioPropertiesProps {
selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void;
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
layers: TimelineLayer[];
timeUnit: 'frames' | 'seconds';
textOverlay: string;
setTextOverlay: (text: string) => void;
activeLayerId: string;
playerRef: RefObject<PlayerRef | null>;
activeTool: 'select' | 'text' | 'sticker' | 'transitions' | 'media';
designMD: DesignMD;
outputFormat?: 'video' | 'image';
onExportClick?: () => void;
onShowRenderHistory?: () => void;
showGrid?: boolean;
setShowGrid?: (show: boolean) => void;
showSafeZone?: boolean;
setShowSafeZone?: (show: boolean) => void;
selectedElementIds?: Set<string>;
clearSelection?: () => void;
}
export const StudioProperties: React.FC<StudioPropertiesProps> = ({
selectedElementId,
setSelectedElementId,
timelineElements,
setTimelineElements,
layers,
timeUnit,
textOverlay,
setTextOverlay,
activeLayerId,
playerRef,
activeTool,
designMD,
outputFormat,
onExportClick,
onShowRenderHistory,
showGrid,
setShowGrid,
showSafeZone,
setShowSafeZone,
selectedElementIds,
clearSelection,
}) => {
const selectedElementIndex = timelineElements.findIndex(s => s.id === selectedElementId);
const selectedElement = selectedElementIndex !== -1 ? timelineElements[selectedElementIndex] : null;
const selectedElementLayer = selectedElement ? layers.find(l => l.id === selectedElement.layerId) : null;
const isBackgroundElement = selectedElementLayer?.type === 'background';
const backgroundElements = timelineElements.filter(el => layers.find(l => l.id === el.layerId)?.type === 'background');
const handleFileUploadBg = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedElement || !isBackgroundElement) return;
const file = e.target.files?.[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const type = isVideo ? 'video' : 'image';
try {
const result = await uploadMedia(file);
const newElements = [...timelineElements];
newElements[selectedElementIndex] = { ...newElements[selectedElementIndex], type, content: result.url };
setTimelineElements(newElements);
} catch (err) {
console.error('Background upload failed:', err);
}
};
const isImageMode = outputFormat === 'image';
return (
<aside className="w-72 bg-neutral-900 border-l border-neutral-800/60 flex flex-col z-10 shrink-0" onClick={(e) => e.stopPropagation()}>
{/* Properties section */}
<div className={isImageMode ? 'shrink-0 border-b border-neutral-800 overflow-y-auto max-h-[50%]' : 'flex-1 overflow-y-auto'}>
{activeTool === 'transitions' ? (
<TransitionsPanel designMD={designMD} />
) : (selectedElementIds && selectedElementIds.size >= 2) ? (
<MultiSelectActions
selectedIds={selectedElementIds}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
clearSelection={clearSelection || (() => {})}
/>
) : selectedElementId ? (
<ElementPropertiesPanel
designMD={designMD}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
timeUnit={timeUnit}
activeLayerId={activeLayerId}
outputFormat={outputFormat}
/>
) : activeLayerId && layers.find(l => l.id === activeLayerId)?.type === 'audio' ? (
<AudioLayerPanel
activeLayerId={activeLayerId}
setTimelineElements={setTimelineElements}
timelineElements={timelineElements}
playerRef={playerRef}
endFrameLimit={timelineElements.find(el => layers.find(l => l.id === el.layerId)?.type === 'background')?.endFrame || 150}
/>
) : activeLayerId && layers.find(l => l.id === activeLayerId)?.type === 'visual' ? (
<GraphicLayerPanel />
) : (
<GlobalSettingsPanel
textOverlay={textOverlay}
setTextOverlay={setTextOverlay}
onExportClick={onExportClick}
onShowRenderHistory={onShowRenderHistory}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
layers={layers}
showGrid={showGrid}
setShowGrid={setShowGrid}
showSafeZone={showSafeZone}
setShowSafeZone={setShowSafeZone}
/>
)}
</div>
{/* Layers panel — image mode only (replaces the hidden timeline) */}
{isImageMode && (
<div className="flex-1 min-h-0 border-t border-neutral-800">
<ImageLayersPanel
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
layers={layers}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
/>
</div>
)}
</aside>
);
};
+730
View File
@@ -0,0 +1,730 @@
import React, { useState, useEffect, useRef, RefObject } from 'react';
import { Layers, GripVertical } from 'lucide-react';
import { TimelineElement, TimelineLayer, DesignMD } from '../types';
import { PlayerRef } from '@remotion/player';
import { DragState, getTrackBgClass } from './timeline/timelineUtils';
import { TimelineControls } from './timeline/TimelineControls';
import { TimelineRuler } from './timeline/TimelineRuler';
import { TimelinePlayhead } from './timeline/TimelinePlayhead';
import { TimelineTrackElement } from './timeline/TimelineTrackElement';
import { TimelineLayerLabels } from './timeline/TimelineLayerLabels';
import { LayerContextMenu } from './timeline/LayerContextMenu';
import { ElementContextMenu } from './timeline/ElementContextMenu';
import { insertSceneTemplate, SceneTemplate } from '../config/sceneTemplates';
import { getAudioDuration, durationToFrames } from '../utils/audioMetadata';
interface StudioTimelineProps {
timelineZoom: number;
setTimelineZoom: (zoom: number) => void;
timeUnit: 'frames' | 'seconds';
setTimeUnit: (unit: 'frames' | 'seconds') => void;
durationInFrames: number;
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
layers: TimelineLayer[];
setLayers: React.Dispatch<React.SetStateAction<TimelineLayer[]>>;
activeLayerId: string;
setActiveLayerId: (id: string) => void;
selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void;
playerRef: RefObject<PlayerRef | null>;
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
outputFormat?: 'video' | 'image';
designMD?: DesignMD;
selectedElementIds?: Set<string>;
toggleElementSelection?: (id: string, multi?: boolean) => void;
}
export const StudioTimeline: React.FC<StudioTimelineProps> = ({
timelineZoom,
setTimelineZoom,
timeUnit,
setTimeUnit,
durationInFrames,
timelineElements,
setTimelineElements,
layers,
setLayers,
activeLayerId,
setActiveLayerId,
selectedElementId,
setSelectedElementId,
playerRef,
activeTool,
outputFormat,
designMD,
selectedElementIds,
toggleElementSelection,
}) => {
const timelineRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);
const [expandedLayers, setExpandedLayers] = useState<Record<string, boolean>>({});
const [draggedLayerId, setDraggedLayerId] = useState<string | null>(null);
const [dragMousePos, setDragMousePos] = useState<{ x: number, y: number } | null>(null);
const [layerContextMenu, setLayerContextMenu] = useState<{ layerId: string, x: number, y: number } | null>(null);
const [elementContextMenu, setElementContextMenu] = useState<{ elementId: string, x: number, y: number } | null>(null);
const [snapGuideFrame, setSnapGuideFrame] = useState<number | null>(null);
const [markers, setMarkers] = useState<number[]>([]);
const transparentImg = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
transparentImg.current = img;
const handleClickOutside = () => setLayerContextMenu(null);
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
// ═══ Playhead Auto-scroll ═══
useEffect(() => {
const player = playerRef.current;
if (!player) return;
let isPlaying = false;
let rafId: number | null = null;
const handlePlay = () => { isPlaying = true; };
const handlePause = () => { isPlaying = false; };
const checkScroll = () => {
if (!isPlaying || !scrollContainerRef.current || !timelineRef.current) {
rafId = requestAnimationFrame(checkScroll);
return;
}
const currentFrame = player.getCurrentFrame();
const scrollEl = scrollContainerRef.current;
const trackWidth = timelineRef.current.offsetWidth;
const playheadX = (currentFrame / durationInFrames) * trackWidth;
const viewportLeft = scrollEl.scrollLeft;
const viewportRight = viewportLeft + scrollEl.clientWidth;
const margin = scrollEl.clientWidth * 0.15; // 15% lookahead margin
if (playheadX > viewportRight - margin || playheadX < viewportLeft + margin * 0.3) {
scrollEl.scrollTo({
left: playheadX - scrollEl.clientWidth * 0.3,
behavior: 'smooth',
});
}
rafId = requestAnimationFrame(checkScroll);
};
player.addEventListener('play', handlePlay);
player.addEventListener('pause', handlePause);
player.addEventListener('ended', handlePause);
rafId = requestAnimationFrame(checkScroll);
return () => {
player.removeEventListener('play', handlePlay);
player.removeEventListener('pause', handlePause);
player.removeEventListener('ended', handlePause);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [playerRef, durationInFrames]);
const toggleLayer = (layerId: string) => {
setExpandedLayers(prev => ({ ...prev, [layerId]: !prev[layerId] }));
};
// --- Layer Drag & Drop ---
const handleDragLayerStart = (e: React.DragEvent, id: string) => {
e.dataTransfer.setData('text/plain', id);
if (transparentImg.current) {
e.dataTransfer.setDragImage(transparentImg.current, 0, 0);
}
setTimeout(() => setDraggedLayerId(id), 0);
};
const handleDropLayer = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (!draggedLayerId || draggedLayerId === targetId) {
setDraggedLayerId(null);
return;
}
const oldIndex = layers.findIndex(l => l.id === draggedLayerId);
const newIndex = layers.findIndex(l => l.id === targetId);
if (oldIndex === -1 || newIndex === -1) {
setDraggedLayerId(null);
return;
}
const newLayers = [...layers];
const [removed] = newLayers.splice(oldIndex, 1);
newLayers.splice(newIndex, 0, removed);
setLayers(newLayers);
setDraggedLayerId(null);
};
// --- Layer Actions ---
const handleToggleLayerLock = (layerId: string) => {
setLayers(layers.map(l => l.id === layerId ? { ...l, isLocked: !l.isLocked } : l));
setLayerContextMenu(null);
};
const handleDuplicateLayer = (layerId: string) => {
const layerToDup = layers.find(l => l.id === layerId);
if (!layerToDup) return;
const newLayerId = 'layer-' + Date.now();
const newLayer = { ...layerToDup, id: newLayerId, name: `${layerToDup.name} (Copia)` };
const layerElements = timelineElements.filter(el => el.layerId === layerId);
const newElements = layerElements.map(el => ({ ...el, id: el.id + '-copy-' + Date.now(), layerId: newLayerId }));
const layerIndex = layers.findIndex(l => l.id === layerId);
const newLayers = [...layers];
newLayers.splice(layerIndex + 1, 0, newLayer);
setLayers(newLayers);
setTimelineElements([...timelineElements, ...newElements]);
setLayerContextMenu(null);
};
const handleDeleteLayer = (layerId: string) => {
setLayers(layers.filter(l => l.id !== layerId));
setTimelineElements(timelineElements.filter(el => el.layerId !== layerId));
if (activeLayerId === layerId) {
const remaining = layers.filter(l => l.id !== layerId);
const fallback = remaining.find(l => l.type !== 'brand') || remaining[0];
if (fallback) setActiveLayerId(fallback.id);
}
setLayerContextMenu(null);
};
// --- Playhead Scrubbing ---
const handleRulerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!timelineRef.current || !playerRef.current) return;
setIsDraggingPlayhead(true);
e.currentTarget.setPointerCapture(e.pointerId);
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const frame = Math.round(percentage * durationInFrames);
playerRef.current.seekTo(frame);
};
const handleRulerPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingPlayhead || !timelineRef.current || !playerRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const frame = Math.round(percentage * durationInFrames);
playerRef.current.seekTo(frame);
};
const handleRulerPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
setIsDraggingPlayhead(false);
e.currentTarget.releasePointerCapture(e.pointerId);
};
// Stable refs for drag handler to prevent effect re-runs mid-drag
const durationRef = useRef(durationInFrames);
durationRef.current = durationInFrames;
const setElementsRef = useRef(setTimelineElements);
setElementsRef.current = setTimelineElements;
// --- Element Drag (move/resize) ---
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragState || !timelineRef.current) return;
const containerWidth = timelineRef.current.clientWidth;
const dur = durationRef.current;
const pxPerFrame = containerWidth / dur;
const deltaPx = e.clientX - dragState.startX;
const deltaFrames = Math.round(deltaPx / pxPerFrame);
setElementsRef.current(prev => {
const draggingId = dragState.id;
const element = prev.find(el => el.id === draggingId);
if (!element) return prev;
let newStart = element.startFrame;
let newEnd = element.endFrame;
if (dragState.type === 'move') {
const duration = dragState.initialEndFrame - dragState.initialStartFrame;
newStart = dragState.initialStartFrame + deltaFrames;
newEnd = newStart + duration;
} else if (dragState.type === 'resize-start') {
newStart = dragState.initialStartFrame + deltaFrames;
} else if (dragState.type === 'resize-end') {
newEnd = dragState.initialEndFrame + deltaFrames;
}
// Snapping
const SNAP_THRESHOLD_PX = 10;
const dynamicSnapThreshold = Math.max(2, Math.round(SNAP_THRESHOLD_PX / pxPerFrame));
const snapPoints: number[] = [0, dur];
if (playerRef.current) {
const playhead = playerRef.current.getCurrentFrame();
if (playhead !== null) snapPoints.push(playhead);
}
prev.forEach(el => {
if (el.id !== draggingId && el.layerId === element.layerId) {
snapPoints.push(el.startFrame, el.endFrame);
}
});
let closestStartSnap = Infinity;
let closestEndSnap = Infinity;
for (const sp of snapPoints) {
if (Math.abs(sp - newStart) < Math.abs(closestStartSnap)) closestStartSnap = sp - newStart;
if (Math.abs(sp - newEnd) < Math.abs(closestEndSnap)) closestEndSnap = sp - newEnd;
}
let activeSnapFrame: number | null = null;
if (dragState.type === 'move') {
if (Math.abs(closestStartSnap) <= dynamicSnapThreshold && Math.abs(closestStartSnap) <= Math.abs(closestEndSnap)) {
newStart += closestStartSnap;
newEnd = newStart + (dragState.initialEndFrame - dragState.initialStartFrame);
activeSnapFrame = newStart;
} else if (Math.abs(closestEndSnap) <= dynamicSnapThreshold) {
newEnd += closestEndSnap;
newStart = newEnd - (dragState.initialEndFrame - dragState.initialStartFrame);
activeSnapFrame = newEnd;
}
} else if (dragState.type === 'resize-start' && Math.abs(closestStartSnap) <= dynamicSnapThreshold) {
newStart += closestStartSnap;
if (newStart >= newEnd) newStart = newEnd - 1;
activeSnapFrame = newStart;
} else if (dragState.type === 'resize-end' && Math.abs(closestEndSnap) <= dynamicSnapThreshold) {
newEnd += closestEndSnap;
if (newEnd <= newStart) newEnd = newStart + 1;
activeSnapFrame = newEnd;
}
setTimeout(() => setSnapGuideFrame(activeSnapFrame), 0);
if (dragState.type === 'move') {
const duration = dragState.initialEndFrame - dragState.initialStartFrame;
newStart = Math.max(0, Math.min(dur - duration, newStart));
newEnd = Math.max(duration, Math.min(dur, newEnd));
} else if (dragState.type === 'resize-start') {
newStart = Math.max(0, Math.min(dragState.initialEndFrame - 1, newStart));
} else if (dragState.type === 'resize-end') {
newEnd = Math.max(newStart + 1, Math.min(dur, newEnd));
}
// Cross-layer dragging: detect vertical movement
let newLayerId = element.layerId;
if (dragState.type === 'move' && dragState.startY) {
const deltaY = e.clientY - dragState.startY;
const LAYER_HEIGHT = 40; // approximate height of each track row
if (Math.abs(deltaY) > LAYER_HEIGHT * 0.5) {
const layerSteps = Math.round(deltaY / LAYER_HEIGHT);
const initialLayerIdx = sortedTrackLayers.findIndex(l => l.id === (dragState.initialLayerId || element.layerId));
const targetIdx = Math.max(0, Math.min(sortedTrackLayers.length - 1, initialLayerIdx + layerSteps));
const targetLayer = sortedTrackLayers[targetIdx];
if (targetLayer && targetLayer.id !== element.layerId && targetLayer.type !== 'brand') {
newLayerId = targetLayer.id;
}
}
}
if (newStart === element.startFrame && newEnd === element.endFrame && newLayerId === element.layerId) return prev;
return prev.map(el => el.id === draggingId ? { ...el, startFrame: newStart, endFrame: newEnd, layerId: newLayerId } : el);
});
};
const handleMouseUp = () => {
if (dragState) {
setDragState(null);
setSnapGuideFrame(null);
}
};
if (dragState) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState]);
// --- Split Element ---
const handleSplitElement = () => {
if (!selectedElementId || !playerRef.current) return;
const currentFrame = playerRef.current.getCurrentFrame() || 0;
const element = timelineElements.find(el => el.id === selectedElementId);
if (element && currentFrame > element.startFrame && currentFrame < element.endFrame) {
const el1 = { ...element, endFrame: currentFrame };
const el2 = { ...element, id: Date.now().toString(), startFrame: currentFrame };
setTimelineElements(prev => prev.map(el => el.id === selectedElementId ? el1 : el).concat(el2));
setSelectedElementId(el2.id);
}
};
// Split & Marker keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
if (e.key.toLowerCase() === 's' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleSplitElement();
} else if (e.key.toLowerCase() === 'm' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
const frame = playerRef.current?.getCurrentFrame() ?? 0;
setMarkers(prev => {
const existing = prev.findIndex(m => Math.abs(m - frame) < 3);
if (existing >= 0) {
// Remove marker
return prev.filter((_, i) => i !== existing);
}
// Add marker
return [...prev, frame].sort((a, b) => a - b);
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedElementId, timelineElements, playerRef]);
// --- Sorted layers for track display ---
const sortedTrackLayers = [
...layers.filter(l => l.type === 'brand'),
...layers.filter(l => l.type === 'background'),
...layers.filter(l => outputFormat !== 'image' && l.type === 'video'),
...layers.filter(l => outputFormat !== 'image' && l.type === 'audio'),
...layers.filter(l => l.type === 'visual' || l.type == null)
];
return (
<div className="StudioTimeline h-64 bg-neutral-900 border-t border-neutral-800 flex flex-col shrink-0 overflow-hidden shadow-[0_-10px_20px_rgba(0,0,0,0.3)]">
<TimelineControls
timelineZoom={timelineZoom}
setTimelineZoom={setTimelineZoom}
timeUnit={timeUnit}
setTimeUnit={setTimeUnit}
durationInFrames={durationInFrames}
selectedElementId={selectedElementId}
onSplit={handleSplitElement}
outputFormat={outputFormat}
onInsertTemplate={(template: SceneTemplate) => {
const frame = playerRef.current?.getCurrentFrame() ?? 0;
const newElements = insertSceneTemplate(template, activeLayerId, frame);
setTimelineElements(prev => [...prev, ...newElements]);
if (newElements.length > 0) setSelectedElementId(newElements[0].id);
}}
/>
{/* Tracks Header & Timeline Ruler Container */}
<div className="flex-1 overflow-hidden flex relative">
{/* Track Labels (Left Side) */}
<TimelineLayerLabels
layers={layers}
setLayers={setLayers}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
activeLayerId={activeLayerId}
setActiveLayerId={setActiveLayerId}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
expandedLayers={expandedLayers}
toggleLayer={toggleLayer}
draggedLayerId={draggedLayerId}
onDragLayerStart={handleDragLayerStart}
onDropLayer={handleDropLayer}
setDraggedLayerId={setDraggedLayerId}
setDragMousePos={setDragMousePos}
setLayerContextMenu={setLayerContextMenu}
playerRef={playerRef}
outputFormat={outputFormat}
durationInFrames={durationInFrames}
designMD={designMD}
/>
{/* Tracks Content (Scrollable) */}
{outputFormat !== 'image' && (
<div ref={scrollContainerRef} className="flex-1 overflow-x-auto overflow-y-auto custom-scrollbar relative bg-[url('https://www.transparenttextures.com/patterns/black-linen.png')] bg-neutral-950/40">
<div
ref={timelineRef}
className="relative min-h-full pb-8 select-none cursor-text"
style={{ width: `${Math.max(100, timelineZoom * 100)}%`, minWidth: '100%' }}
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
handleRulerPointerDown(e);
}
}}
onPointerMove={isDraggingPlayhead ? handleRulerPointerMove : undefined}
onPointerUp={isDraggingPlayhead ? handleRulerPointerUp : undefined}
>
{/* Ruler */}
<TimelineRuler
timeUnit={timeUnit}
durationInFrames={durationInFrames}
onPointerDown={handleRulerPointerDown}
onPointerMove={handleRulerPointerMove}
onPointerUp={handleRulerPointerUp}
/>
{/* Playhead */}
<TimelinePlayhead
playerRef={playerRef}
durationInFrames={durationInFrames}
onPointerDown={handleRulerPointerDown}
onPointerMove={handleRulerPointerMove}
onPointerUp={handleRulerPointerUp}
isDraggingPlayhead={isDraggingPlayhead}
/>
{/* Snap Guide */}
{snapGuideFrame !== null && (
<div
className="absolute top-0 bottom-0 w-px bg-yellow-400 z-20 shadow-[0_0_8px_rgba(250,204,21,0.8)] pointer-events-none"
style={{ left: `${(snapGuideFrame / durationInFrames) * 100}%` }}
/>
)}
{/* Markers */}
{markers.map((frame, idx) => (
<div
key={`marker-${idx}`}
className="absolute top-0 bottom-0 z-15 pointer-events-none"
style={{ left: `${(frame / durationInFrames) * 100}%` }}
>
{/* Marker head (triangle) */}
<div className="absolute top-0 -translate-x-1/2" style={{ width: 0, height: 0, borderLeft: '4px solid transparent', borderRight: '4px solid transparent', borderTop: '6px solid #34d399' }} />
{/* Marker line */}
<div className="absolute top-1.5 bottom-0 w-px bg-emerald-400/30" style={{ left: 0 }} />
</div>
))}
{/* Tracks */}
<div className="py-2 space-y-2 w-full">
{sortedTrackLayers.map(layer => {
const isExpanded = expandedLayers[layer.id];
const layerElements = timelineElements.filter(el => el.layerId === layer.id);
return (
<div key={`layer-track-group-${layer.id}`} className={`flex flex-col gap-2 rounded-l -ml-1 pl-1 ${getTrackBgClass(layer.colorLabel)}`}>
<div
className="relative h-8 w-full group"
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}}
onDrop={(e) => {
e.preventDefault();
const data = e.dataTransfer.getData('application/json');
if (data) {
try {
const parsed = JSON.parse(data);
if (parsed.type && parsed.src) {
const elementType = parsed.type === 'sticker' ? 'image' : (parsed.type === 'images' || parsed.type === 'image') ? 'image' : parsed.type === 'video' ? 'video' : parsed.type === 'audio' ? 'audio' : 'image';
// Validate layer compatibility
if (layer.type === 'brand') return; // Brand never accepts drops
if (layer.type === 'video' && elementType !== 'video') return;
if (layer.type === 'audio' && elementType !== 'audio') return;
if ((layer.type === 'visual' || layer.type == null) && (elementType === 'video' || elementType === 'audio')) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const frame = Math.round(percentage * durationInFrames);
setTimelineElements(prev => [...prev, {
id: 'el-' + Date.now(),
layerId: layer.id,
type: elementType,
content: parsed.src,
startFrame: frame,
endFrame: Math.min(durationInFrames, frame + 60),
x: 0,
y: 0,
scale: 1,
originalFileName: parsed.fileName,
}]);
// Auto-detect audio duration and update endFrame
if (elementType === 'audio') {
const elId = 'el-' + Date.now();
getAudioDuration(parsed.src).then(dur => {
const realEnd = frame + durationToFrames(dur);
setTimelineElements(p => p.map(el =>
el.content === parsed.src && el.startFrame === frame && el.type === 'audio'
? { ...el, endFrame: realEnd }
: el
));
}).catch(() => {});
}
}
} catch (err) {}
}
}}
>
{/* Track Background Line */}
<div className="absolute top-1/2 w-full h-px bg-neutral-800/30 transform -translate-y-1/2"></div>
{!isExpanded && layerElements.map(el => (
<TimelineTrackElement
key={`track-${el.id}`}
element={el}
layer={layer}
layerElements={layerElements}
durationInFrames={durationInFrames}
selectedElementId={selectedElementId}
dragState={dragState}
activeTool={activeTool}
setSelectedElementId={setSelectedElementId}
setActiveLayerId={setActiveLayerId}
setDragState={setDragState}
setTimelineElements={setTimelineElements}
setElementContextMenu={setElementContextMenu}
playerRef={playerRef}
timelineElements={timelineElements}
selectedElementIds={selectedElementIds}
toggleElementSelection={toggleElementSelection}
/>
))}
</div>
{isExpanded && (
<div className="flex flex-col gap-2">
{layerElements.map((el, elIdx) => (
<div key={`track-el-${el.id}`} className="relative h-8 w-full group flex items-center">
<div className="absolute top-1/2 w-full h-px bg-neutral-800/20 transform -translate-y-1/2"></div>
<div className="sticky left-4 z-[5] w-fit flex items-center gap-1.5 opacity-40 group-hover:opacity-100 transition-opacity pointer-events-none">
<span className="text-[9px] text-neutral-400 font-mono font-medium bg-neutral-900/90 w-4 h-4 flex items-center justify-center rounded border border-neutral-800 shadow-sm backdrop-blur-md">
{elIdx + 1}
</span>
<span className="text-[9px] text-neutral-400 font-medium bg-neutral-900/90 px-1.5 py-0.5 rounded border border-neutral-800 shadow-sm backdrop-blur-md">
{layer.name}
</span>
<span className="text-[9px] text-neutral-600 font-medium truncate max-w-[150px]">
{el.type === 'text' ? el.content : el.type === 'audio' ? 'Audio Track' : el.type === 'image' ? 'Imagen' : el.type === 'video' ? 'Video' : 'Sticker'}
</span>
</div>
<TimelineTrackElement
element={el}
layer={layer}
layerElements={layerElements}
durationInFrames={durationInFrames}
selectedElementId={selectedElementId}
dragState={dragState}
activeTool={activeTool}
setSelectedElementId={setSelectedElementId}
setActiveLayerId={setActiveLayerId}
setDragState={setDragState}
setTimelineElements={setTimelineElements}
setElementContextMenu={setElementContextMenu}
playerRef={playerRef}
timelineElements={timelineElements}
selectedElementIds={selectedElementIds}
toggleElementSelection={toggleElementSelection}
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
{/* Context Menu layer */}
{layerContextMenu && (
<LayerContextMenu
layerContextMenu={layerContextMenu}
layers={layers}
setLayers={setLayers}
onToggleLock={handleToggleLayerLock}
onDuplicate={handleDuplicateLayer}
onDelete={handleDeleteLayer}
onClose={() => setLayerContextMenu(null)}
/>
)}
{/* Context Menu element */}
{elementContextMenu && (() => {
const ctxEl = timelineElements.find(e => e.id === elementContextMenu.elementId);
if (!ctxEl) return null;
return (
<ElementContextMenu
elementId={elementContextMenu.elementId}
x={elementContextMenu.x}
y={elementContextMenu.y}
element={ctxEl}
onClose={() => setElementContextMenu(null)}
onDuplicate={(id) => {
const src = timelineElements.find(e => e.id === id);
if (!src || src.isBrandElement) return;
const copy = { ...src, id: 'el-' + Date.now(), isBrandElement: false, isLocked: false };
setTimelineElements(prev => [...prev, copy]);
setSelectedElementId(copy.id);
}}
onDelete={(id) => {
setTimelineElements(prev => prev.filter(e => e.id !== id));
setSelectedElementId(null);
}}
onToggleLock={(id) => {
setTimelineElements(prev => prev.map(e =>
e.id === id ? { ...e, isLocked: !e.isLocked } : e
));
}}
onSplit={(id) => {
const splitEl = timelineElements.find(e => e.id === id);
const frame = playerRef.current?.getCurrentFrame() ?? 0;
if (splitEl && frame > splitEl.startFrame + 2 && frame < splitEl.endFrame - 2) {
const second = { ...splitEl, id: 'el-' + Date.now(), startFrame: frame, isBrandElement: false };
setTimelineElements(prev => {
const idx = prev.findIndex(e => e.id === id);
const arr = [...prev];
arr[idx] = { ...splitEl, endFrame: frame };
arr.splice(idx + 1, 0, second);
return arr;
});
}
}}
onBringForward={(id) => {
setTimelineElements(prev => {
const idx = prev.findIndex(e => e.id === id);
if (idx < 0 || idx === prev.length - 1) return prev;
const arr = [...prev];
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
return arr;
});
}}
onSendBackward={(id) => {
setTimelineElements(prev => {
const idx = prev.findIndex(e => e.id === id);
if (idx <= 0) return prev;
const arr = [...prev];
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
return arr;
});
}}
/>
);
})()}
{/* Drag Ghost Indicator */}
{draggedLayerId && dragMousePos && (
<div
className="fixed pointer-events-none z-[100] w-64 bg-neutral-800/90 backdrop-blur-sm border border-neutral-600 rounded shadow-2xl flex items-center px-2 h-8 gap-1.5"
style={{ left: dragMousePos.x + 15, top: dragMousePos.y + 15 }}
>
<GripVertical size={12} className="text-neutral-400" />
<Layers size={12} className="text-neutral-300 shrink-0" />
<span className="text-[10px] font-medium text-white truncate px-1">
{layers.find(l => l.id === draggedLayerId)?.name || 'Capa'}
</span>
</div>
)}
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import { FolderOpen, Type, Stamp, Music, Settings2, Hexagon, HelpCircle, Disc3 } from 'lucide-react';
export type PanelType = 'media' | 'text' | 'stickers' | 'shapes' | 'audio' | 'sfx' | null;
interface StudioToolbarProps {
activePanel: PanelType;
setActivePanel: (panel: PanelType) => void;
onShowShortcuts?: () => void;
outputFormat?: 'video' | 'image';
}
/**
* CapCut-style sidebar toolbar (56px wide).
* Each button toggles a sliding panel on the right side.
* Always visible — no buttons change or disappear based on layer type.
*/
export const StudioToolbar: React.FC<StudioToolbarProps> = ({
activePanel,
setActivePanel,
onShowShortcuts,
outputFormat,
}) => {
const ToolButton = ({ panel, icon, label }: { panel: PanelType; icon: React.ReactNode; label: string }) => {
const isActive = activePanel === panel;
return (
<button
onClick={() => setActivePanel(isActive ? null : panel)}
title={label}
className={`relative w-full flex flex-col items-center justify-center gap-0.5 py-2.5 transition-all ${
isActive
? 'text-white bg-neutral-800/70'
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/30'
}`}
>
{/* Active accent line */}
{isActive && (
<div className="absolute left-0 top-1.5 bottom-1.5 w-[2px] bg-violet-500 rounded-r" />
)}
{icon}
<span className="text-[8px] font-medium leading-none mt-0.5">{label}</span>
</button>
);
};
return (
<div className="w-14 bg-neutral-900 border-r border-neutral-800/60 flex flex-col items-center z-20 shrink-0">
<ToolButton
panel="media"
icon={<FolderOpen size={18} />}
label="Media"
/>
<ToolButton
panel="text"
icon={<Type size={18} />}
label="Texto"
/>
<ToolButton
panel="stickers"
icon={<Stamp size={18} />}
label="Marca"
/>
<ToolButton
panel="shapes"
icon={<Hexagon size={18} />}
label="Formas"
/>
{outputFormat !== 'image' && (
<ToolButton
panel="audio"
icon={<Music size={18} />}
label="Audio"
/>
)}
{outputFormat !== 'image' && (
<ToolButton
panel="sfx"
icon={<Disc3 size={18} />}
label="SFX"
/>
)}
<div className="flex-1" />
{/* Help */}
<button
onClick={onShowShortcuts}
title="Atajos de Teclado (?)"
className="relative w-full flex flex-col items-center justify-center gap-0.5 py-2.5 transition-all text-neutral-600 hover:text-neutral-300 hover:bg-neutral-800/30"
>
<HelpCircle size={18} />
<span className="text-[8px] font-medium leading-none mt-0.5">Ayuda</span>
</button>
{/* Settings */}
<button
onClick={() => setActivePanel(null)}
title="Cerrar Paneles"
className={`relative w-full flex flex-col items-center justify-center gap-0.5 py-2.5 mb-1 transition-all ${
activePanel === null
? 'text-white bg-neutral-800/60'
: 'text-neutral-600 hover:text-neutral-300 hover:bg-neutral-800/30'
}`}
>
<Settings2 size={18} />
<span className="text-[8px] font-medium leading-none mt-0.5">Ajustes</span>
</button>
</div>
);
};
+626
View File
@@ -0,0 +1,626 @@
import React, { RefObject, useState, useCallback, useEffect } from 'react';
import { Player, PlayerRef } from '@remotion/player';
import { BrandComposition } from './BrandComposition';
import { RenderProps, TimelineElement } from '../types';
import { PlaySquare } from 'lucide-react';
import { CanvasWorkspace } from './ui/CanvasWorkspace';
import { useEditor } from '../context/EditorContext';
import { ElementActionToolbar } from './composition/ElementActionToolbar';
import { SAFE_AREAS } from '../config/constants';
import { getAudioDuration, durationToFrames } from '../utils/audioMetadata';
interface StudioWorkspaceProps {
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
setSelectedElementId: (id: string | null) => void;
selectedElementId?: string | null;
playerRef: RefObject<PlayerRef | null>;
compositionProps: RenderProps;
durationInFrames: number;
timelineElements?: TimelineElement[];
setTimelineElements?: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
aspectRatio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3';
setAspectRatio: (ratio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3') => void;
outputFormat?: 'video' | 'image';
activeLayerId?: string;
/** Lifted zoom state for TopHeader integration */
zoom: number;
setZoom: React.Dispatch<React.SetStateAction<number>>;
}
export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
activeTool,
setSelectedElementId,
selectedElementId,
playerRef,
compositionProps,
durationInFrames,
timelineElements,
setTimelineElements,
aspectRatio,
setAspectRatio,
outputFormat,
activeLayerId,
zoom,
setZoom,
}) => {
const [showControls, setShowControls] = useState(false);
const [showContextMenu, setShowContextMenu] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const lastPanPos = React.useRef({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [safeAreaPlatform, setSafeAreaPlatform] = useState<keyof typeof SAFE_AREAS | null>(null);
const { activeAction, setActiveAction } = useEditor();
// Keyboard shortcuts for action modes (M/S/R) and element actions (D/Delete)
useEffect(() => {
if (!selectedElementId) return;
const handleKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const selectedEl = timelineElements?.find(el => el.id === selectedElementId);
if (!selectedEl) return;
switch (e.key.toLowerCase()) {
case 'm': setActiveAction('move'); break;
case 's': setActiveAction('scale'); break;
case 'r': setActiveAction('rotate'); break;
case 'd':
e.preventDefault();
compositionProps.onElementDuplicate?.(selectedEl.id);
break;
case 'backspace':
case 'delete':
if (!selectedEl.isBrandElement) {
e.preventDefault();
compositionProps.onElementDelete?.(selectedEl.id);
}
break;
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [selectedElementId, timelineElements, setActiveAction, compositionProps]);
// Reset to move when element is deselected
useEffect(() => {
if (!selectedElementId) setActiveAction('move');
}, [selectedElementId, setActiveAction]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' && (e.target as HTMLElement).tagName !== 'INPUT' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
setIsSpacePressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
setIsSpacePressed(false);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
}
}, []);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button === 1 || isSpacePressed) {
e.preventDefault();
setIsPanning(true);
lastPanPos.current = { x: e.clientX, y: e.clientY };
} else {
setSelectedElementId(null);
}
};
const handlePointerMove = (e: React.PointerEvent) => {
if (isPanning) {
const dx = e.clientX - lastPanPos.current.x;
const dy = e.clientY - lastPanPos.current.y;
setPan(prev => ({ x: prev.x + dx, y: prev.y + dy }));
lastPanPos.current = { x: e.clientX, y: e.clientY };
}
};
const handlePointerUp = () => {
setIsPanning(false);
};
// Ref for native wheel handler (React onWheel is passive, can't preventDefault)
const canvasRef = React.useRef<HTMLElement>(null);
React.useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
const zoomDelta = -e.deltaY * 0.008;
setZoom(prev => {
const next = Math.min(5, Math.max(0.1, prev + zoomDelta * prev));
return Math.round(next * 100) / 100;
});
} else {
setPan(prev => ({ x: prev.x - e.deltaX, y: prev.y - e.deltaY }));
}
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [setZoom]);
const selectedElement = timelineElements?.find(el => el.id === selectedElementId);
const handleUpdateSelected = (updates: Partial<TimelineElement>) => {
if (setTimelineElements && selectedElementId) {
setTimelineElements(prev => prev.map(el => el.id === selectedElementId ? { ...el, ...updates } : el));
}
};
const handleTextEditComplete = () => {
setEditingTextId(null);
};
const { layers, setLayers, setActiveLayerId } = useEditor();
// ═══ Drop handler for canvas ═══
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const data = e.dataTransfer.getData('application/json');
if (!data || !setTimelineElements) return;
try {
const parsed = JSON.parse(data);
if (!parsed.type || !parsed.src) return;
// Calculate drop position relative to canvas
const canvasRect = e.currentTarget.getBoundingClientRect();
const x = Math.round(((e.clientX - canvasRect.left) / canvasRect.width) * 100);
const y = Math.round(((e.clientY - canvasRect.top) / canvasRect.height) * 100);
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const elementType = parsed.type === 'sticker' ? 'image'
: (parsed.type === 'images' || parsed.type === 'image') ? 'image'
: parsed.type === 'video' ? 'video'
: parsed.type === 'audio' ? 'audio'
: 'image';
// Determine target layer based on element type
let targetLayerId = activeLayerId || 'layer-1';
const activeLayer = layers.find(l => l.id === activeLayerId);
// Brand layer never accepts new elements — route to correct layer
const isIncompatibleLayer = activeLayer?.type === 'brand'
|| (elementType === 'video' && activeLayer?.type !== 'video')
|| (elementType === 'audio' && activeLayer?.type !== 'audio')
|| (elementType !== 'video' && elementType !== 'audio' && (activeLayer?.type === 'video' || activeLayer?.type === 'audio'));
if (elementType === 'video' && (isIncompatibleLayer || activeLayer?.type !== 'video')) {
let videoLayer = layers.find(l => l.type === 'video');
if (!videoLayer) {
const count = layers.filter(l => l.type === 'video').length + 1;
videoLayer = { id: 'layer-' + Date.now(), name: `Capa de Video ${count}`, type: 'video' };
setLayers(prev => [...prev, videoLayer!]);
}
targetLayerId = videoLayer.id;
setActiveLayerId(targetLayerId);
} else if (elementType === 'audio' && (isIncompatibleLayer || activeLayer?.type !== 'audio')) {
let audioLayer = layers.find(l => l.type === 'audio');
if (!audioLayer) {
const count = layers.filter(l => l.type === 'audio').length + 1;
audioLayer = { id: 'layer-' + Date.now(), name: `Capa de Audio ${count}`, type: 'audio', volume: 100 };
setLayers(prev => [...prev, audioLayer!]);
}
targetLayerId = audioLayer.id;
setActiveLayerId(targetLayerId);
} else if (isIncompatibleLayer) {
// Image/sticker → visual layer
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (visualLayer) {
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
}
setTimelineElements(prev => [...prev, {
id: 'el-' + Date.now(),
layerId: targetLayerId,
type: elementType,
content: parsed.src,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: Math.max(5, Math.min(95, x)),
y: Math.max(5, Math.min(95, y)),
scale: 1,
originalFileName: parsed.fileName,
}]);
// Auto-detect audio duration and update endFrame
if (elementType === 'audio') {
getAudioDuration(parsed.src).then(dur => {
const realEnd = currentFrame + durationToFrames(dur);
setTimelineElements(p => p.map(el =>
el.content === parsed.src && el.startFrame === currentFrame && el.type === 'audio'
? { ...el, endFrame: realEnd }
: el
));
}).catch(() => {});
}
} catch {}
}, [setTimelineElements, activeLayerId, durationInFrames, playerRef, layers, setLayers, setActiveLayerId]);
const getDimensions = () => {
if (aspectRatio === '16:9') return { width: 1920, height: 1080 };
if (aspectRatio === '1:1') return { width: 1080, height: 1080 };
if (aspectRatio === '4:5') return { width: 1080, height: 1350 };
if (aspectRatio === '4:3') return { width: 1440, height: 1080 };
return { width: 1080, height: 1920 }; // 9:16
};
const dimensions = getDimensions();
return (
<main
ref={canvasRef}
className={`flex-1 relative flex flex-col justify-center items-center p-4 overflow-hidden checkerboard-bg ${isSpacePressed ? 'cursor-grab' : ''} ${isPanning ? 'cursor-grabbing' : ''} ${isDragOver ? 'drop-zone-active' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
{/* Player controls toggle — video only, top-right small */}
{outputFormat !== 'image' && (
<div className="absolute top-2 right-2 z-10 flex gap-1">
{/* Safe Area toggle */}
{aspectRatio === '9:16' && (
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setSafeAreaPlatform(prev => prev ? null : 'tiktok'); }}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors ${safeAreaPlatform ? 'bg-amber-600/80 text-white' : 'bg-neutral-800/60 hover:bg-neutral-700/60 text-neutral-400'}`}
title="Safe Area"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2" /><rect x="7" y="7" width="10" height="10" rx="1" strokeDasharray="2 2" /></svg>
</button>
{safeAreaPlatform && (
<div className="absolute right-0 top-full mt-1 bg-neutral-900 border border-neutral-700 rounded-lg p-1 shadow-xl min-w-[120px]">
{Object.entries(SAFE_AREAS).map(([key, sa]) => (
<button
key={key}
onClick={(e) => { e.stopPropagation(); setSafeAreaPlatform(key as keyof typeof SAFE_AREAS); }}
className={`block w-full text-left px-2 py-1 rounded text-[10px] transition-colors ${safeAreaPlatform === key ? 'bg-amber-600/30 text-amber-200' : 'text-neutral-300 hover:bg-neutral-800'}`}
>
{sa.label}
</button>
))}
<button
onClick={(e) => { e.stopPropagation(); setSafeAreaPlatform(null); }}
className="block w-full text-left px-2 py-1 rounded text-[10px] text-rose-400 hover:bg-neutral-800"
>
Desactivar
</button>
</div>
)}
</div>
)}
<button
onClick={(e) => { e.stopPropagation(); setShowControls(!showControls); }}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors ${showControls ? 'bg-violet-600/80 text-white' : 'bg-neutral-800/60 hover:bg-neutral-700/60 text-neutral-400'}`}
title={showControls ? "Ocultar Controles" : "Mostrar Controles"}
>
<PlaySquare size={12} />
</button>
</div>
)}
{/* ═══ Fixed Action Toolbar — above canvas ═══ */}
{selectedElementId && (() => {
const selectedEl = timelineElements?.find(el => el.id === selectedElementId);
if (!selectedEl) return null;
const isInteractive = (!!activeLayerId && selectedEl.layerId === activeLayerId) && !selectedEl.isLocked;
if (!isInteractive) return null;
// Keyframe state
const currentFrame = playerRef.current?.getCurrentFrame() ?? 0;
const kfs = selectedEl.keyframes ?? [];
const hasKeyframes = kfs.length > 0;
const kfAtFrame = kfs.find(kf => Math.abs(kf.frame - currentFrame) <= 2);
const hasKeyframeAtCurrentFrame = !!kfAtFrame;
const handleToggleKeyframe = () => {
if (!setTimelineElements) return;
setTimelineElements(prev => prev.map(el => {
if (el.id !== selectedEl.id) return el;
const existing = (el.keyframes ?? []);
const atIdx = existing.findIndex(kf => Math.abs(kf.frame - currentFrame) <= 2);
if (atIdx >= 0) {
// Remove keyframe
const newKfs = existing.filter((_, i) => i !== atIdx);
return { ...el, keyframes: newKfs.length > 0 ? newKfs : undefined };
} else {
// Add keyframe with current element values
const newKf = {
frame: currentFrame,
x: el.x,
y: el.y,
scale: el.scale ?? 1,
opacity: el.opacity ?? 1,
rotation: el.rotation ?? 0,
easing: 'ease-in-out' as const,
};
// If no keyframes yet, also add one at startFrame with current values
if (existing.length === 0) {
const startKf = { ...newKf, frame: el.startFrame, easing: 'linear' as const };
return { ...el, keyframes: [startKf, newKf].sort((a, b) => a.frame - b.frame) };
}
return { ...el, keyframes: [...existing, newKf].sort((a, b) => a.frame - b.frame) };
}
}));
};
const handlePrevKeyframe = () => {
const prev = [...kfs].filter(kf => kf.frame < currentFrame - 2).sort((a, b) => b.frame - a.frame);
if (prev.length > 0) playerRef.current?.seekTo(prev[0].frame);
};
const handleNextKeyframe = () => {
const next = [...kfs].filter(kf => kf.frame > currentFrame + 2).sort((a, b) => a.frame - b.frame);
if (next.length > 0) playerRef.current?.seekTo(next[0].frame);
};
return (
<div
className="absolute top-2 left-1/2 -translate-x-1/2 z-20"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<ElementActionToolbar
activeAction={activeAction}
setActiveAction={setActiveAction}
isLocked={!!selectedEl.isLocked}
isBrandElement={!!selectedEl.isBrandElement}
onDuplicate={() => compositionProps.onElementDuplicate?.(selectedEl.id)}
onDelete={() => compositionProps.onElementDelete?.(selectedEl.id)}
onLock={() => compositionProps.onElementLock?.(selectedEl.id)}
hasKeyframes={hasKeyframes}
hasKeyframeAtCurrentFrame={hasKeyframeAtCurrentFrame}
onToggleKeyframe={handleToggleKeyframe}
onPrevKeyframe={handlePrevKeyframe}
onNextKeyframe={handleNextKeyframe}
/>
</div>
);
})()}
<div
className={`relative ${activeTool === 'select' ? 'cursor-default' : 'cursor-crosshair'} h-full w-full max-h-full flex items-center justify-center`}
onClick={(e) => {
e.stopPropagation();
setSelectedElementId(null);
setShowContextMenu(false);
}}
onContextMenu={(e) => {
e.preventDefault();
}}
>
<div
className={`relative flex items-center justify-center ${isPanning ? '' : 'transition-transform duration-100 ease-out'}`}
style={{
height: '100%',
maxHeight: '100%',
maxWidth: '100%',
aspectRatio: `${dimensions.width} / ${dimensions.height}`,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<CanvasWorkspace
aspectRatio={`${dimensions.width} / ${dimensions.height}`}
isEditing={!!selectedElementId}
className=""
canvasClassName="rounded"
overlay={selectedElementId ? (
<>
{/* Text editor overlay */}
{editingTextId && selectedElement && selectedElement.type === 'text' && (
<div
className="absolute z-30"
style={{
left: `${selectedElement.x}%`,
top: `${selectedElement.y}%`,
transform: `translate(-50%, -50%) scale(${selectedElement.scale ?? 1})`,
}}
onClick={e => e.stopPropagation()}
>
<textarea
autoFocus
value={selectedElement.content}
onChange={(e) => handleUpdateSelected({ content: e.target.value })}
onBlur={handleTextEditComplete}
onKeyDown={(e) => {
if (e.key === 'Escape') {
handleTextEditComplete();
}
e.stopPropagation();
}}
className="bg-transparent border-2 border-violet-500 rounded-lg p-2 outline-none resize-none text-center"
style={{
fontFamily: selectedElement.fontFamily ?? compositionProps.designMD.baseFont,
color: selectedElement.color ?? compositionProps.designMD.textColor,
fontSize: `calc(${(selectedElement.fontSize || 56)}px * (100vh / 1920) * 0.8)`,
textShadow: `${selectedElement.shadowOffset ?? 3}px ${selectedElement.shadowOffset ?? 3}px ${selectedElement.shadowBlur ?? 6}px rgba(0,0,0,0.8)`,
width: '600px',
minHeight: '200px',
lineHeight: '1.2',
pointerEvents: 'auto',
}}
/>
</div>
)}
{/* Context menu overlay */}
{selectedElement && showContextMenu && (
<div
className="absolute z-20 flex items-center gap-4 bg-[#111] border border-neutral-800 shadow-2xl px-5 py-3 rounded-2xl backdrop-blur-xl transition-all duration-75"
style={{
left: `${selectedElement.x}%`,
top: `calc(${selectedElement.y}% + 4rem)`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'auto',
}}
onClick={e => e.stopPropagation()}
onContextMenu={e => e.preventDefault()}
>
{selectedElement.type === 'text' && (
<>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Color</label>
<div className="relative w-8 h-8 rounded-lg overflow-hidden border border-neutral-700/50">
<input
type="color"
value={selectedElement.color || compositionProps.designMD.textColor}
onChange={(e) => handleUpdateSelected({ color: e.target.value })}
className="absolute -top-2 -left-2 w-12 h-12 cursor-pointer bg-transparent border-0"
/>
</div>
</div>
<div className="w-[1px] h-10 bg-neutral-800/80 mx-1"></div>
<div className="flex flex-col gap-1.5 w-24">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Tamaño</label>
<input
type="number"
value={selectedElement.fontSize || 56}
onChange={(e) => handleUpdateSelected({ fontSize: Number(e.target.value) })}
className="w-full bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors"
/>
</div>
<div className="w-[1px] h-10 bg-neutral-800/80 mx-1"></div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Fuente</label>
<select
value={selectedElement.fontFamily || compositionProps.designMD.baseFont}
onChange={(e) => handleUpdateSelected({ fontFamily: e.target.value })}
className="bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors min-w-[140px]"
>
<option value="system-ui, sans-serif">System Default</option>
<option value="Inter, sans-serif">Inter</option>
<option value="'Space Grotesk', sans-serif">Space Grotesk</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'JetBrains Mono', monospace">JetBrains Mono</option>
</select>
</div>
</>
)}
{selectedElement.type === 'sticker' && (
<>
<div className="flex flex-col gap-1.5 w-24">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Escala</label>
<input
type="number"
step="0.1"
min="0.1"
value={selectedElement.scale ?? 1}
onChange={(e) => handleUpdateSelected({ scale: Number(e.target.value) })}
className="w-full bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors"
/>
</div>
<div className="w-[1px] h-10 bg-neutral-800/80 mx-1"></div>
<div className="flex flex-col gap-1.5 w-24">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Opacidad</label>
<input
type="number"
step="0.1"
min="0"
max="1"
value={selectedElement.opacity ?? 1}
onChange={(e) => handleUpdateSelected({ opacity: Number(e.target.value) })}
className="w-full bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors"
/>
</div>
</>
)}
</div>
)}
</>
) : undefined}
>
<Player
ref={playerRef}
component={BrandComposition}
inputProps={{
...compositionProps,
onElementClick: (id) => {
setShowContextMenu(false);
if (editingTextId !== id) {
setEditingTextId(null);
}
if (compositionProps.onElementClick) {
compositionProps.onElementClick(id);
}
},
onElementContextMenu: (id) => {
setSelectedElementId(id);
setShowContextMenu(true);
},
onElementDoubleClick: (id) => {
const element = timelineElements?.find(el => el.id === id);
if (element?.type === 'text') {
setSelectedElementId(id);
setEditingTextId(id);
setShowContextMenu(false);
}
}
}}
durationInFrames={durationInFrames}
compositionWidth={dimensions.width}
compositionHeight={dimensions.height}
fps={30}
controls={outputFormat !== 'image' && showControls}
clickToPlay={false}
style={{
width: '100%',
height: '100%',
borderRadius: '4px',
pointerEvents: isSpacePressed || isPanning ? 'none' : 'auto',
boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
}}
/>
{/* Safe Area Overlay */}
{safeAreaPlatform && SAFE_AREAS[safeAreaPlatform] && (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 40, borderRadius: '4px', overflow: 'hidden' }}>
{/* Top danger zone */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: `${SAFE_AREAS[safeAreaPlatform].top}%`, background: 'rgba(245, 158, 11, 0.15)', borderBottom: '1px dashed rgba(245, 158, 11, 0.6)' }} />
{/* Bottom danger zone */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${SAFE_AREAS[safeAreaPlatform].bottom}%`, background: 'rgba(245, 158, 11, 0.15)', borderTop: '1px dashed rgba(245, 158, 11, 0.6)' }} />
{/* Left danger zone */}
<div style={{ position: 'absolute', top: `${SAFE_AREAS[safeAreaPlatform].top}%`, bottom: `${SAFE_AREAS[safeAreaPlatform].bottom}%`, left: 0, width: `${SAFE_AREAS[safeAreaPlatform].left}%`, background: 'rgba(245, 158, 11, 0.08)' }} />
{/* Right danger zone */}
<div style={{ position: 'absolute', top: `${SAFE_AREAS[safeAreaPlatform].top}%`, bottom: `${SAFE_AREAS[safeAreaPlatform].bottom}%`, right: 0, width: `${SAFE_AREAS[safeAreaPlatform].right}%`, background: 'rgba(245, 158, 11, 0.08)' }} />
{/* Label */}
<div style={{ position: 'absolute', top: 4, left: '50%', transform: 'translateX(-50%)', background: 'rgba(245, 158, 11, 0.9)', color: '#000', fontSize: 9, fontWeight: 600, padding: '1px 6px', borderRadius: 4 }}>
{SAFE_AREAS[safeAreaPlatform].label} Safe Area
</div>
</div>
)}
</CanvasWorkspace>
</div>
</div>
</main>
);
};
+183
View File
@@ -0,0 +1,183 @@
import React, { useState } from 'react';
import { LayoutTemplate, Menu, Home, Settings, Download, ZoomIn, ZoomOut, X, CalendarDays, Sparkles, Play } from 'lucide-react';
interface TopHeaderProps {
currentStep: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
setCurrentStep: (step: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form') => void;
/** Open Express editor with blank canvas, no brand */
onStartExpressBlank?: () => void;
/** Open Pro editor with blank canvas, no brand */
onStartProBlank?: () => void;
outputFormat?: 'video' | 'image';
/** Zoom controls — passed up from StudioWorkspace */
zoom?: number;
onZoomIn?: () => void;
onZoomOut?: () => void;
onZoomReset?: () => void;
/** Aspect ratio controls */
aspectRatio?: '16:9' | '1:1' | '9:16' | '4:5' | '4:3';
onAspectRatioChange?: (ratio: '16:9' | '1:1' | '9:16' | '4:5' | '4:3') => void;
}
export const TopHeader: React.FC<TopHeaderProps> = ({
currentStep,
setCurrentStep,
outputFormat,
onStartExpressBlank,
onStartProBlank,
zoom = 1,
onZoomIn,
onZoomOut,
onZoomReset,
aspectRatio = '9:16',
onAspectRatioChange,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
const isStudio = currentStep === 'studio';
return (
<header className="flex-none border-b border-neutral-800/60 bg-neutral-900/95 backdrop-blur-sm px-3 h-11 flex items-center justify-between z-30 relative">
{/* Left: Hamburger + Logo */}
<div className="flex items-center gap-2">
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
title="Menú principal"
className="p-1.5 rounded-md text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
>
<Menu size={16} />
</button>
{/* Dropdown menu */}
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute top-full left-0 mt-1 w-52 bg-neutral-800 border border-neutral-700 rounded-lg shadow-2xl z-50 py-1 animate-in">
<button
onClick={() => { setCurrentStep('dashboard'); setMenuOpen(false); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<Home size={14} /> Ir al Dashboard
</button>
{currentStep !== 'brand' && (
<button
onClick={() => { setCurrentStep('brand'); setMenuOpen(false); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<Settings size={14} /> Editar Marca
</button>
)}
<button
onClick={() => { setCurrentStep('content-grid'); setMenuOpen(false); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<CalendarDays size={14} /> Malla de Contenidos
</button>
<div className="h-px bg-neutral-700 my-1" />
<button
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<Download size={14} /> Descargar
</button>
</div>
</>
)}
</div>
<div className="flex items-center gap-2">
<div className="bg-violet-600/20 p-1 rounded text-violet-400">
<LayoutTemplate size={14} />
</div>
<span className="text-xs font-semibold text-white tracking-tight">SaaS Branding</span>
</div>
</div>
{/* Center: Zoom controls (only in studio) */}
{isStudio && onZoomIn && onZoomOut && (
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); onZoomOut(); }}
title="Zoom Out"
className="p-1 text-neutral-500 hover:text-white rounded transition-colors"
>
<ZoomOut size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onZoomReset?.(); }}
title="Restablecer zoom"
className="text-[11px] font-mono text-neutral-400 hover:text-white w-12 text-center rounded py-0.5 hover:bg-neutral-800 transition-colors"
>
{Math.round(zoom * 100)}%
</button>
<button
onClick={(e) => { e.stopPropagation(); onZoomIn(); }}
title="Zoom In"
className="p-1 text-neutral-500 hover:text-white rounded transition-colors"
>
<ZoomIn size={14} />
</button>
{/* Aspect ratio pills */}
{onAspectRatioChange && (
<>
<div className="w-px h-4 bg-neutral-700 mx-1" />
{(['16:9', '9:16', '1:1', '4:5', '4:3'] as const).map(ratio => (
<button
key={ratio}
onClick={(e) => { e.stopPropagation(); onAspectRatioChange(ratio); }}
title={`Formato ${ratio}`}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
aspectRatio === ratio
? 'bg-neutral-700 text-white'
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
}`}
>
{ratio}
</button>
))}
</>
)}
</div>
)}
{/* Right: Editor buttons + Format badge */}
<div className="flex items-center gap-2">
{/* Express / Pro buttons — only on dashboard */}
{currentStep === 'dashboard' && onStartExpressBlank && (
<button
onClick={onStartExpressBlank}
title="Crear desde cero con el editor rápido (sin marca)"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-violet-600/80 to-fuchsia-600/80 hover:from-violet-500 hover:to-fuchsia-500 text-white text-[10px] font-bold transition-all shadow-sm hover:shadow-md"
>
<Sparkles size={12} />
Express
</button>
)}
{currentStep === 'dashboard' && onStartProBlank && (
<button
onClick={onStartProBlank}
title="Crear desde cero con el editor profesional (sin marca)"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-white text-[10px] font-semibold transition-all"
>
<Play size={12} />
Editor Pro 🎛
</button>
)}
{isStudio && (
<span className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">
{outputFormat === 'image' ? 'Imagen' : 'Video'}
</span>
)}
{currentStep !== 'dashboard' && !isStudio && (
<button
onClick={() => setCurrentStep('dashboard')}
className="flex items-center gap-1.5 px-3 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-md text-xs font-medium transition-all"
>
Dashboard
</button>
)}
</div>
</header>
);
};
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import { Building2 } from 'lucide-react';
import { DesignMD } from '../../types';
interface BrandCardProps {
designMD: DesignMD;
width?: number;
height?: number;
className?: string;
/** Scale transform applied to the card */
scale?: number;
}
/**
* Reusable brand preview card that renders a mock frame
* showing how the DesignMD looks. Used in Dashboard and BrandPreview.
* All typography sizes scale proportionally to the card dimensions.
*/
export const BrandCard: React.FC<BrandCardProps> = ({
designMD,
width = 320,
height = 480,
className = '',
scale = 1,
}) => {
// Scale factor relative to a 1080-wide composition
const sf = width / 1080;
const pad = Math.max(12, Math.round(24 * sf * 4));
const titleFontSize = Math.round(Math.min(designMD.titleSize || 64, 64) * sf * 2.2);
const subtitleFontSize = Math.round(Math.min(designMD.subtitleSize || 32, 32) * sf * 2.2);
const paragraphFontSize = Math.max(8, Math.round(Math.min(designMD.paragraphSize || 16, 16) * sf * 2.2));
const logoWidth = Math.max(32, Math.round(120 * sf * 2));
return (
<div
className={`relative shadow-2xl transition-all duration-500 ease-out flex flex-col overflow-hidden ${className}`}
style={{
width,
height,
backgroundColor: designMD.secondaryColor,
border: `${Math.max(1, Math.round(designMD.frameThickness * sf * 2))}px solid ${designMD.primaryColor}`,
borderRadius: Math.max(8, Math.round(16 * sf * 2)),
padding: pad,
transform: `scale(${scale})`,
transformOrigin: 'center center'
}}
>
{/* Logo */}
<div className="shrink-0">
{designMD.logoUrl ? (
<img
src={designMD.logoUrl}
alt="Logo"
style={{ width: logoWidth, maxHeight: logoWidth * 0.6, objectFit: 'contain' }}
className="filter drop-shadow-md"
/>
) : (
<div className="flex items-center gap-1.5 opacity-30">
<Building2 size={Math.max(12, logoWidth * 0.3)} style={{ color: designMD.textColor }} />
</div>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Typography Preview */}
<div
className="shrink-0 text-center border border-white/5"
style={{
backgroundColor: 'rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
padding: `${Math.max(8, pad * 0.8)}px ${Math.max(6, pad * 0.6)}px`,
borderRadius: Math.max(6, Math.round(16 * sf * 2)),
}}
>
<h1
style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.titleColor || designMD.textColor,
fontSize: titleFontSize,
lineHeight: 1.15,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
className="font-bold tracking-tight"
>
Título Principal
</h1>
<h2
style={{
fontFamily: designMD.subtitleFont || designMD.baseFont,
color: designMD.subtitleColor || designMD.textColor,
fontSize: subtitleFontSize,
lineHeight: 1.25,
marginTop: Math.max(2, Math.round(8 * sf * 2)),
}}
className="font-medium opacity-90"
>
Subtítulo de marca
</h2>
<p
style={{
fontFamily: designMD.paragraphFont || designMD.baseFont,
color: designMD.paragraphColor || designMD.textColor,
fontSize: paragraphFontSize,
lineHeight: 1.5,
marginTop: Math.max(2, Math.round(6 * sf * 2)),
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
className="opacity-70 font-light"
>
Este es un párrafo de ejemplo que muestra el estilo del texto extendido.
</p>
</div>
{/* Color palette strip */}
<div className="flex gap-1 mt-2 justify-center shrink-0">
<div
className="rounded-full"
style={{ width: Math.max(6, 10 * sf * 2), height: Math.max(6, 10 * sf * 2), backgroundColor: designMD.primaryColor }}
/>
<div
className="rounded-full border border-white/10"
style={{ width: Math.max(6, 10 * sf * 2), height: Math.max(6, 10 * sf * 2), backgroundColor: designMD.secondaryColor }}
/>
<div
className="rounded-full"
style={{ width: Math.max(6, 10 * sf * 2), height: Math.max(6, 10 * sf * 2), backgroundColor: designMD.textColor }}
/>
</div>
</div>
);
};
+203
View File
@@ -0,0 +1,203 @@
import React, { useState } from 'react';
import { Settings2, ZoomIn, ZoomOut, Play, BarChart3, Monitor, Square, Smartphone } from 'lucide-react';
import { DesignMD, CompanyProfile } from '../../types';
import { BrandCard } from './BrandCard';
import { PreviewCompanyCard } from './previews/PreviewCompanyCard';
import { PreviewTypography } from './previews/PreviewTypography';
import { PreviewTimeline } from './previews/PreviewTimeline';
import { PreviewRemotion } from './previews/PreviewRemotion';
type BrandTab = 'general' | 'visual' | 'typography' | 'media';
type AspectRatio = '16:9' | '1:1' | '9:16';
interface BrandPreviewProps {
designMD: DesignMD;
company: CompanyProfile;
activeTab: BrandTab;
zoom: number;
setZoom: React.Dispatch<React.SetStateAction<number>>;
aspectRatio: AspectRatio;
setAspectRatio: (ratio: AspectRatio) => void;
handleDesignChange?: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
const TAB_SUBTITLES: Record<BrandTab, string> = {
general: 'Así se presenta tu empresa',
visual: 'Así luce el sistema de diseño estricto',
typography: 'Jerarquía tipográfica de tu marca',
media: 'Preview en vivo del resultado final',
};
const RATIO_ICONS: Record<AspectRatio, React.ReactNode> = {
'16:9': <Monitor size={13} />,
'1:1': <Square size={13} />,
'9:16': <Smartphone size={13} />,
};
const RATIO_LABELS: Record<AspectRatio, string> = {
'16:9': 'Landscape',
'1:1': 'Cuadrado',
'9:16': 'Vertical',
};
export const BrandPreview: React.FC<BrandPreviewProps> = ({
designMD,
company,
activeTab,
zoom,
setZoom,
aspectRatio,
setAspectRatio,
handleDesignChange,
}) => {
const showZoomControls = activeTab === 'visual';
const showAspectRatio = activeTab === 'visual' || activeTab === 'media';
const [mediaView, setMediaView] = useState<'player' | 'info'>('player');
const getDimensions = () => {
if (aspectRatio === '16:9') return { width: 480, height: 270 };
if (aspectRatio === '1:1') return { width: 360, height: 360 };
return { width: 320, height: 480 }; // 9:16
};
const dimensions = getDimensions();
return (
<div className="w-1/2 bg-neutral-950 p-8 flex flex-col relative overflow-hidden">
{/* Header */}
<div className="flex items-start justify-between mb-6 shrink-0">
<div>
<p className="text-xs uppercase tracking-widest text-neutral-600 font-bold mb-1">Previsualización</p>
<p className="text-sm text-neutral-400">{TAB_SUBTITLES[activeTab]}</p>
</div>
<div className="flex items-center gap-2">
{/* Aspect Ratio selector — for visual & media */}
{showAspectRatio && (
<div className="flex bg-neutral-900 rounded-lg p-1 border border-neutral-800 shadow-xl text-sm items-center">
{(['16:9', '1:1', '9:16'] as const).map(ratio => (
<button
key={ratio}
onClick={() => setAspectRatio(ratio)}
title={`${RATIO_LABELS[ratio]} (${ratio})`}
className={`px-2.5 py-1.5 rounded-md transition-colors flex items-center gap-1.5 ${
aspectRatio === ratio
? 'bg-neutral-800 text-white font-medium shadow-sm'
: 'text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800/50'
}`}
>
{RATIO_ICONS[ratio]}
<span className="text-xs">{ratio}</span>
</button>
))}
</div>
)}
{/* Zoom controls — visual tab only */}
{showZoomControls && (
<div className="flex bg-neutral-900 rounded-lg p-1 border border-neutral-800 shadow-xl text-sm items-center">
<button
onClick={() => setZoom(1)}
title="Restablecer Vista"
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50 rounded-md transition-colors"
>
<Settings2 size={16} />
</button>
<div className="w-[1px] h-4 bg-neutral-800 mx-1"></div>
<button
onClick={() => setZoom(prev => Math.max(0.25, prev - 0.25))}
title="Zoom Out"
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50 rounded-md transition-colors"
>
<ZoomOut size={16} />
</button>
<span className="text-neutral-400 text-xs w-10 text-center font-mono">
{Math.round(zoom * 100)}%
</span>
<button
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
title="Zoom In"
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50 rounded-md transition-colors"
>
<ZoomIn size={16} />
</button>
</div>
)}
{/* Media tab: toggle between player and info */}
{activeTab === 'media' && (
<div className="flex bg-neutral-900 rounded-lg p-1 border border-neutral-800 shadow-xl text-sm items-center">
<button
onClick={() => setMediaView('player')}
title="Vista de video en vivo"
className={`p-1.5 px-3 rounded-md transition-colors flex items-center gap-1.5 ${
mediaView === 'player'
? 'bg-neutral-800 text-white font-medium shadow-sm'
: 'text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50'
}`}
>
<Play size={14} />
<span className="text-xs">Video</span>
</button>
<button
onClick={() => setMediaView('info')}
title="Vista de estructura"
className={`p-1.5 px-3 rounded-md transition-colors flex items-center gap-1.5 ${
mediaView === 'info'
? 'bg-neutral-800 text-white font-medium shadow-sm'
: 'text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50'
}`}
>
<BarChart3 size={14} />
<span className="text-xs">Estructura</span>
</button>
</div>
)}
</div>
</div>
{/* Preview Content — full remaining space */}
<div className="flex-1 flex items-center justify-center overflow-auto min-h-0">
{activeTab === 'general' && (
<PreviewCompanyCard company={company} designMD={designMD} />
)}
{activeTab === 'visual' && (
<BrandCard
designMD={designMD}
width={dimensions.width}
height={dimensions.height}
scale={zoom}
/>
)}
{activeTab === 'typography' && (
<div className="flex items-center gap-6 max-h-full overflow-auto">
<PreviewTypography designMD={designMD} />
</div>
)}
{activeTab === 'media' && mediaView === 'player' && (
<div className="flex flex-col h-full w-full min-h-0">
<div className="flex-1 min-h-0">
<PreviewRemotion
designMD={designMD}
company={company}
aspectRatio={aspectRatio}
onDesignChange={handleDesignChange}
/>
</div>
</div>
)}
{activeTab === 'media' && mediaView === 'info' && (
<div className="overflow-auto max-h-full w-full max-w-md mx-auto">
<PreviewTimeline designMD={designMD} aspectRatio={aspectRatio} />
</div>
)}
</div>
</div>
);
};
+136
View File
@@ -0,0 +1,136 @@
import React from 'react';
import { Link2, Instagram, AtSign, Play, Globe } from 'lucide-react';
import { CompanyProfile } from '../../types';
const INDUSTRIES = [
'Tecnología',
'Moda y Lifestyle',
'Salud y Bienestar',
'Educación',
'Restaurante y Food',
'Fitness y Deporte',
'Finanzas',
'Entretenimiento',
'E-commerce',
'Otro'
];
interface BrandTabGeneralProps {
company: CompanyProfile;
handleCompanyChange: (company: CompanyProfile) => void;
}
export const BrandTabGeneral: React.FC<BrandTabGeneralProps> = ({ company, handleCompanyChange }) => {
return (
<div className="space-y-6">
{/* Company Details */}
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase">Información de la Marca</h3>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2">Nombre de la Empresa</label>
<input
type="text"
value={company?.name || ''}
onChange={(e) => handleCompanyChange({ ...company, name: e.target.value })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all font-medium"
placeholder="Ej. TechFlow"
/>
</div>
{/* Tagline */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2">
Tagline / Eslogan
<span className="text-xs text-neutral-500 font-normal">(opcional)</span>
</label>
<input
type="text"
value={company?.tagline || ''}
onChange={(e) => handleCompanyChange({ ...company, tagline: e.target.value })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="Ej. Innovación que transforma"
maxLength={80}
/>
</div>
{/* Industry */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-2 flex items-center gap-2">
Industria
<span className="text-xs text-neutral-500 font-normal">(opcional)</span>
</label>
<div className="grid grid-cols-2 gap-2">
{INDUSTRIES.map(ind => (
<button
key={ind}
type="button"
onClick={() => handleCompanyChange({
...company,
industry: company.industry === ind ? undefined : ind
})}
className={`px-3 py-2 text-xs font-medium rounded-lg border transition-all text-left ${
company.industry === ind
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-950 border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-300'
}`}
>
{ind}
</button>
))}
</div>
</div>
</div>
</div>
{/* Social Links */}
<div className="space-y-4 pt-4 border-t border-neutral-800">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase flex items-center gap-2">
<Globe size={16} /> Redes y Presencia
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><Link2 size={14} /> Website</label>
<input
type="text"
value={company?.socialLinks?.website || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, website: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="https://"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><Instagram size={14} /> Instagram</label>
<input
type="text"
value={company?.socialLinks?.instagram || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, instagram: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="@usuario"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><AtSign size={14} /> TikTok</label>
<input
type="text"
value={company?.socialLinks?.tiktok || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, tiktok: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="@usuario"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><Play size={14} /> YouTube</label>
<input
type="text"
value={company?.socialLinks?.youtube || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, youtube: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="@canal"
/>
</div>
</div>
</div>
</div>
);
};
+258
View File
@@ -0,0 +1,258 @@
import React, { useCallback } from 'react';
import { Film, Volume2, Music, X, Upload } from 'lucide-react';
import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
interface BrandTabMediaProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
/**
* BrandTabMedia — Upload-only panel for brand video/audio assets.
*
* Only handles uploading the intro video, outro video, and brand audio.
* All positioning, fit, duration, and blend controls live in the TemplateBuilder
* (per-template segment configuration), avoiding collisions.
*/
export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDesignChange }) => {
/** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
if (!url) return;
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (video.duration && isFinite(video.duration)) {
const frames = Math.round(video.duration * 30); // 30fps
handleDesignChange(key, Math.max(15, Math.min(300, frames)));
}
video.remove();
};
video.onerror = () => video.remove();
video.src = url;
}, [handleDesignChange]);
return (
<div className="space-y-5">
{/* Section title */}
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Film size={16} className="text-violet-400" />
Archivos de Video y Audio
</h3>
<p className="text-xs text-neutral-500 leading-relaxed">
Sube los videos y audio de tu marca. La posición, duración y estilo se configuran en cada plantilla.
</p>
</div>
{/* ═══ Intro Video ═══ */}
<VideoUploadSimple
label="Video de Cabezote (Intro)"
description="Se usará automáticamente en plantillas que incluyan segmento de intro de marca"
videoUrl={designMD.introVideoUrl || ''}
accentColor="#10b981"
onUrlChange={(url) => {
handleDesignChange('introVideoUrl', url);
if (url) probeVideoDuration(url, 'introDurationFrames');
}}
onClear={() => {
handleDesignChange('introVideoUrl', '');
handleDesignChange('introDurationFrames', 60);
}}
/>
{/* ═══ Outro Video ═══ */}
<VideoUploadSimple
label="Video de Cierre (Outro)"
description="Se usará automáticamente en plantillas que incluyan segmento de outro de marca"
videoUrl={designMD.outroVideoUrl || ''}
accentColor="#f43f5e"
onUrlChange={(url) => {
handleDesignChange('outroVideoUrl', url);
if (url) probeVideoDuration(url, 'outroDurationFrames');
}}
onClear={() => {
handleDesignChange('outroVideoUrl', '');
handleDesignChange('outroDurationFrames', 60);
}}
/>
{/* ═══ Brand Audio ═══ */}
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Música / Jingle de Marca
</label>
{designMD.brandAudioUrl && (
<button
onClick={() => handleDesignChange('brandAudioUrl', '')}
title="Quitar audio de marca"
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">
Se incluirá como pista de fondo en plantillas de video
</p>
<div className="flex gap-3 items-start">
{/* Preview */}
<div className="w-14 h-14 rounded-lg bg-neutral-950 border border-neutral-800 flex items-center justify-center shrink-0">
{designMD.brandAudioUrl ? (
<div className="flex items-end gap-0.5 h-6">
{[3, 5, 4, 6, 3].map((h, i) => (
<div
key={i}
className="w-1 bg-violet-500 rounded-full animate-pulse"
style={{ height: `${h * 3}px`, animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
) : (
<Music size={20} className="text-neutral-600" />
)}
</div>
{/* Upload controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={designMD.brandAudioUrl || ''}
onChange={(e) => handleDesignChange('brandAudioUrl', e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://audio.mp3"
/>
<FileDropZone
compact
accept="audio/*"
label="Subir audio"
onFiles={(files) => {
const url = URL.createObjectURL(files[0]);
handleDesignChange('brandAudioUrl', url);
}}
/>
</div>
</div>
{/* Volume slider */}
{designMD.brandAudioUrl && (
<div className="flex items-center gap-3 pt-1">
<Volume2 size={12} className="text-neutral-500 shrink-0" />
<span className="text-[10px] text-neutral-500 shrink-0">Volumen:</span>
<input
type="range"
min="0"
max="100"
value={Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}
onChange={(e) => handleDesignChange('brandAudioVolume', parseInt(e.target.value) / 100)}
className="flex-1 h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
/>
<span className="text-[10px] font-mono text-violet-300 bg-neutral-800 px-1.5 py-0.5 rounded shrink-0">
{Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}%
</span>
</div>
)}
</div>
</div>
);
};
/* ── Simple Video Upload Card ── */
const VideoUploadSimple: React.FC<{
label: string;
description: string;
videoUrl: string;
accentColor: string;
onUrlChange: (url: string) => void;
onClear: () => void;
}> = ({ label, description, videoUrl, accentColor, onUrlChange, onClear }) => {
const hasVideo = !!videoUrl && videoUrl.trim().length > 0;
return (
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Film size={14} style={{ color: accentColor }} />
{label}
</label>
{hasVideo && (
<button
onClick={onClear}
title={`Quitar ${label}`}
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">{description}</p>
<div className="flex gap-3 items-start">
{/* Video Preview */}
<div className="w-28 h-20 rounded-lg overflow-hidden bg-neutral-950 border border-neutral-800 shrink-0 flex items-center justify-center">
{hasVideo ? (
<video
src={videoUrl}
className="w-full h-full object-cover"
muted
playsInline
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const v = e.target as HTMLVideoElement;
v.pause();
v.currentTime = 0;
}}
/>
) : (
<div className="text-neutral-600 flex flex-col items-center gap-1">
<Upload size={18} style={{ color: `${accentColor}60` }} />
<span className="text-[9px]">Sin video</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={videoUrl}
onChange={(e) => onUrlChange(e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://video.mp4"
/>
<FileDropZone
compact
accept="video/*"
label="Subir archivo"
onFiles={(files) => {
const url = URL.createObjectURL(files[0]);
onUrlChange(url);
}}
/>
</div>
</div>
{/* Status badge */}
{hasVideo && (
<div
className="flex items-center gap-1.5 text-[10px] font-medium px-2.5 py-1 rounded-lg w-fit"
style={{
backgroundColor: `${accentColor}15`,
color: accentColor,
border: `1px solid ${accentColor}30`,
}}
>
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
Video cargado
</div>
)}
</div>
);
};
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
import { Type } from 'lucide-react';
import { DesignMD } from '../../types';
import { FontPicker } from '../ui/FontPicker';
interface BrandTabTypographyProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
export const BrandTabTypography: React.FC<BrandTabTypographyProps> = ({ designMD, handleDesignChange }) => {
return (
<div className="space-y-6">
{/* Sistema Tipográfico Jerárquico */}
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase flex items-center gap-2"><Type size={16} /> Sistema Tipográfico</h3>
{/* Fuente Base */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Fuente Base</label>
<p className="text-[10px] text-neutral-500 -mt-1">Se usa como fallback cuando un rol tipográfico no tiene fuente asignada</p>
<FontPicker
value={designMD.baseFont}
onChange={(font) => handleDesignChange('baseFont', font)}
/>
</div>
{/* Títulos */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Estilo para Títulos</label>
<div className="grid grid-cols-[2fr_1fr_1fr] gap-3">
<FontPicker
value={designMD.titleFont || designMD.baseFont}
onChange={(font) => handleDesignChange('titleFont', font)}
brandFont={designMD.baseFont}
/>
<input
type="number"
value={designMD.titleSize || 64}
onChange={(e) => handleDesignChange('titleSize', Number(e.target.value))}
className="bg-neutral-900 text-sm rounded-lg px-3 py-2 border border-neutral-800 outline-none font-mono text-white"
placeholder="Size"
/>
<div className="flex items-center bg-neutral-900 border border-neutral-800 rounded-lg px-2">
<input
type="color"
value={designMD.titleColor || designMD.textColor}
onChange={(e) => handleDesignChange('titleColor', e.target.value)}
className="w-6 h-6 rounded bg-neutral-800 border-none cursor-pointer"
/>
</div>
</div>
</div>
{/* Subtítulos */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Estilo para Subtítulos</label>
<div className="grid grid-cols-[2fr_1fr_1fr] gap-3">
<FontPicker
value={designMD.subtitleFont || designMD.baseFont}
onChange={(font) => handleDesignChange('subtitleFont', font)}
brandFont={designMD.baseFont}
/>
<input
type="number"
value={designMD.subtitleSize || 32}
onChange={(e) => handleDesignChange('subtitleSize', Number(e.target.value))}
className="bg-neutral-900 text-sm rounded-lg px-3 py-2 border border-neutral-800 outline-none font-mono text-white"
placeholder="Size"
/>
<div className="flex items-center bg-neutral-900 border border-neutral-800 rounded-lg px-2">
<input
type="color"
value={designMD.subtitleColor || designMD.textColor}
onChange={(e) => handleDesignChange('subtitleColor', e.target.value)}
className="w-6 h-6 rounded bg-neutral-800 border-none cursor-pointer"
/>
</div>
</div>
</div>
{/* Párrafos */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Estilo para Párrafos</label>
<div className="grid grid-cols-[2fr_1fr_1fr] gap-3">
<FontPicker
value={designMD.paragraphFont || designMD.baseFont}
onChange={(font) => handleDesignChange('paragraphFont', font)}
brandFont={designMD.baseFont}
/>
<input
type="number"
value={designMD.paragraphSize || 16}
onChange={(e) => handleDesignChange('paragraphSize', Number(e.target.value))}
className="bg-neutral-900 text-sm rounded-lg px-3 py-2 border border-neutral-800 outline-none font-mono text-white"
placeholder="Size"
/>
<div className="flex items-center bg-neutral-900 border border-neutral-800 rounded-lg px-2">
<input
type="color"
value={designMD.paragraphColor || designMD.textColor}
onChange={(e) => handleDesignChange('paragraphColor', e.target.value)}
className="w-6 h-6 rounded bg-neutral-800 border-none cursor-pointer"
/>
</div>
</div>
</div>
</div>
</div>
);
};
+137
View File
@@ -0,0 +1,137 @@
import React, { useCallback } from 'react';
import { Settings2, ImageIcon } from 'lucide-react';
import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
interface BrandTabVisualProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
designMD,
handleDesignChange,
}) => {
const handleLogoFiles = useCallback((files: File[]) => {
const file = files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
handleDesignChange('logoUrl', event.target.result as string);
}
};
reader.readAsDataURL(file);
}, [handleDesignChange]);
return (
<div className="space-y-6">
{/* Logo */}
<div className="space-y-2">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase">Identidad Visual</h3>
<label className="block text-sm font-medium text-neutral-300 mb-2">Logo Corporativo</label>
<div className="flex gap-4 items-start">
<div className="w-24 h-24 rounded-xl bg-white flex items-center justify-center p-3 shrink-0 border border-neutral-700 shadow-lg">
{designMD.logoUrl ? (
<img src={designMD.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
) : (
<ImageIcon size={32} className="text-neutral-300" />
)}
</div>
<div className="flex-1 space-y-2">
<input
type="text"
value={designMD.logoUrl}
onChange={(e) => handleDesignChange('logoUrl', e.target.value)}
className="bg-neutral-900 text-sm rounded-lg px-4 py-2.5 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://logo.svg"
/>
<FileDropZone
compact
accept="image/png, image/jpeg, image/svg+xml, image/webp"
label="Subir desde archivo"
onFiles={handleLogoFiles}
/>
</div>
</div>
</div>
{/* Colors Grid */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-xl">
<label className="block text-xs font-medium text-neutral-400 mb-3">Color Primario (Marco)</label>
<div className="flex items-center gap-3">
<input
type="color"
value={designMD.primaryColor}
onChange={(e) => handleDesignChange('primaryColor', e.target.value)}
className="w-10 h-10 rounded-lg shrink-0 bg-neutral-800 border-none cursor-pointer"
/>
<input
type="text"
value={designMD.primaryColor}
onChange={(e) => handleDesignChange('primaryColor', e.target.value)}
className="bg-neutral-950 text-sm rounded uppercase px-3 py-2 w-full border border-neutral-800 outline-none font-mono text-neutral-300"
/>
</div>
</div>
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-xl">
<label className="block text-xs font-medium text-neutral-400 mb-3">Color Secundario (Fondo)</label>
<div className="flex items-center gap-3">
<input
type="color"
value={designMD.secondaryColor}
onChange={(e) => handleDesignChange('secondaryColor', e.target.value)}
className="w-10 h-10 rounded-lg shrink-0 bg-neutral-800 border-none cursor-pointer"
/>
<input
type="text"
value={designMD.secondaryColor}
onChange={(e) => handleDesignChange('secondaryColor', e.target.value)}
className="bg-neutral-950 text-sm rounded uppercase px-3 py-2 w-full border border-neutral-800 outline-none font-mono text-neutral-300"
/>
</div>
</div>
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-xl">
<label className="block text-xs font-medium text-neutral-400 mb-3">Color de Texto Base</label>
<div className="flex items-center gap-3">
<input
type="color"
value={designMD.textColor}
onChange={(e) => handleDesignChange('textColor', e.target.value)}
className="w-10 h-10 rounded-lg shrink-0 bg-neutral-800 border-none cursor-pointer"
/>
<input
type="text"
value={designMD.textColor}
onChange={(e) => handleDesignChange('textColor', e.target.value)}
className="bg-neutral-950 text-sm rounded uppercase px-3 py-2 w-full border border-neutral-800 outline-none font-mono text-neutral-300"
/>
</div>
</div>
</div>
{/* Frame Thickness */}
<div className="space-y-4 pt-4 border-t border-neutral-800">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase flex items-center gap-2"><Settings2 size={16} /> Configuración Base</h3>
<div>
<label className="flex justify-between text-sm font-medium text-neutral-300 mb-4">
<span>Espesor del Marco Perimetral</span>
<span className="bg-neutral-800 px-2 py-0.5 rounded text-violet-300 text-xs font-mono">{designMD.frameThickness}px</span>
</label>
<input
type="range"
min="0"
max="80"
value={designMD.frameThickness}
onChange={(e) => handleDesignChange('frameThickness', parseInt(e.target.value))}
className="w-full h-2 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
/>
</div>
</div>
</div>
);
};
+144
View File
@@ -0,0 +1,144 @@
import React, { useState, useRef, useEffect } from 'react';
import { X, Briefcase, Sparkles } from 'lucide-react';
const INDUSTRIES = [
'Tecnología',
'Moda y Lifestyle',
'Salud y Bienestar',
'Educación',
'Restaurante y Food',
'Fitness y Deporte',
'Finanzas',
'Entretenimiento',
'E-commerce',
'Otro'
];
interface CreateBrandModalProps {
onConfirm: (name: string, industry?: string) => void;
onCancel: () => void;
}
export const CreateBrandModal: React.FC<CreateBrandModalProps> = ({ onConfirm, onCancel }) => {
const [name, setName] = useState('');
const [industry, setIndustry] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const isValid = name.trim().length >= 2;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isValid) {
onConfirm(name.trim(), industry || undefined);
}
};
return (
<div className="fixed inset-0 z-[999] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Modal */}
<div className="relative bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden animate-in">
{/* Header */}
<div className="flex items-center justify-between p-6 pb-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-violet-600/20 border border-violet-500/30 flex items-center justify-center">
<Briefcase size={18} className="text-violet-400" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Nueva Marca</h2>
<p className="text-xs text-neutral-400">Define la identidad de tu empresa</p>
</div>
</div>
<button
onClick={onCancel}
title="Cerrar"
className="text-neutral-500 hover:text-white p-1.5 rounded-lg hover:bg-neutral-800 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-2">
Nombre de la Marca <span className="text-rose-400">*</span>
</label>
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className={`w-full bg-neutral-950 border rounded-xl px-4 py-3 text-white text-lg font-medium focus:outline-none focus:ring-2 transition-all placeholder:text-neutral-600 ${
name.length > 0 && !isValid
? 'border-rose-500/50 focus:ring-rose-500/30'
: 'border-neutral-800 focus:ring-violet-500/30 focus:border-violet-500/50'
}`}
placeholder="Ej. TechFlow, Neon Fashion..."
maxLength={50}
/>
{name.length > 0 && !isValid && (
<p className="text-xs text-rose-400 mt-1.5">El nombre debe tener al menos 2 caracteres</p>
)}
</div>
{/* Industry */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-2">
Industria <span className="text-neutral-500">(opcional)</span>
</label>
<div className="grid grid-cols-2 gap-2">
{INDUSTRIES.map(ind => (
<button
key={ind}
type="button"
onClick={() => setIndustry(industry === ind ? '' : ind)}
className={`px-3 py-2 text-xs font-medium rounded-lg border transition-all text-left ${
industry === ind
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-950 border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-300'
}`}
>
{ind}
</button>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-3 rounded-xl border border-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors font-medium text-sm"
>
Cancelar
</button>
<button
type="submit"
disabled={!isValid}
className={`flex-1 px-4 py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all ${
isValid
? 'bg-violet-600 hover:bg-violet-500 text-white shadow-lg shadow-violet-900/30'
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed'
}`}
>
<Sparkles size={16} />
Crear Marca
</button>
</div>
</form>
</div>
</div>
);
};
+72
View File
@@ -0,0 +1,72 @@
import React, { useState } from 'react';
interface TransitionCardProps {
value: string;
label: string;
icon: string;
selected: boolean;
onSelect: () => void;
}
/**
* Compact transition selector with CSS micro-animation on hover.
*/
export const TransitionCard: React.FC<TransitionCardProps> = ({
value,
label,
icon,
selected,
onSelect,
}) => {
const [hovering, setHovering] = useState(false);
return (
<button
type="button"
onClick={onSelect}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
title={`Transición: ${label}`}
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border transition-all text-left group ${
selected
? 'bg-violet-600/20 border-violet-500/60 text-white'
: 'bg-neutral-950/50 border-neutral-800/60 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300 hover:bg-neutral-900'
}`}
>
<span
className="w-5 h-5 flex items-center justify-center text-sm shrink-0 transition-all duration-500"
style={getAnimationStyle(value, hovering)}
>
{icon}
</span>
<span className="text-[11px] font-medium leading-none truncate">{label}</span>
</button>
);
};
function getAnimationStyle(value: string, hovering: boolean): React.CSSProperties {
if (!hovering) return {};
switch (value) {
case 'fade':
return { opacity: 0, transition: 'opacity 0.4s ease-in-out', animation: 'fadeInCard 0.6s ease forwards' };
case 'slideUp':
return { transform: 'translateY(6px)', animation: 'slideUpCard 0.5s ease forwards' };
case 'slideRight':
return { transform: 'translateX(-6px)', animation: 'slideRightCard 0.5s ease forwards' };
case 'typewriter':
return { opacity: 0.3, animation: 'typewriterCard 0.6s steps(4) forwards' };
case 'bounce':
return { animation: 'bounceCard 0.6s ease forwards' };
case 'scale':
return { transform: 'scale(0.3)', animation: 'scaleCard 0.4s ease forwards' };
case 'crossfade':
return { opacity: 0.3, animation: 'fadeInCard 0.8s ease forwards' };
case 'dipToBlack':
return { opacity: 0, animation: 'dipToBlackCard 0.8s ease forwards' };
case 'flash':
return { animation: 'flashCard 0.4s ease forwards' };
default:
return {};
}
}
@@ -0,0 +1,121 @@
import React from 'react';
import { Globe, Instagram, AtSign, Play, Building2 } from 'lucide-react';
import { CompanyProfile, DesignMD } from '../../../types';
interface PreviewCompanyCardProps {
company: CompanyProfile;
designMD: DesignMD;
}
/**
* Live corporate identity card showing company data in real-time.
*/
export const PreviewCompanyCard: React.FC<PreviewCompanyCardProps> = ({ company, designMD }) => {
const socialEntries = [
{ icon: <Globe size={14} />, value: company.socialLinks?.website, label: 'Web' },
{ icon: <Instagram size={14} />, value: company.socialLinks?.instagram, label: 'Instagram' },
{ icon: <AtSign size={14} />, value: company.socialLinks?.tiktok, label: 'TikTok' },
{ icon: <Play size={14} />, value: company.socialLinks?.youtube, label: 'YouTube' },
].filter(s => s.value);
return (
<div
className="w-[340px] rounded-2xl overflow-hidden shadow-2xl transition-all duration-500"
style={{ border: `3px solid ${designMD.primaryColor}` }}
>
{/* Header band */}
<div
className="px-6 py-8 flex flex-col items-center text-center relative"
style={{ backgroundColor: designMD.primaryColor }}
>
{/* Logo */}
<div className="w-20 h-20 rounded-2xl bg-white flex items-center justify-center p-3 shadow-xl mb-4">
{designMD.logoUrl ? (
<img src={designMD.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
) : (
<Building2 size={32} className="text-neutral-300" />
)}
</div>
{/* Name */}
<h2
className="text-xl font-bold tracking-tight"
style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.textColor,
}}
>
{company.name || 'Nombre de Marca'}
</h2>
{/* Tagline */}
{company.tagline && (
<p
className="text-sm mt-1.5 opacity-80"
style={{
fontFamily: designMD.subtitleFont || designMD.baseFont,
color: designMD.textColor,
}}
>
"{company.tagline}"
</p>
)}
</div>
{/* Body */}
<div
className="px-6 py-5 space-y-4"
style={{ backgroundColor: designMD.secondaryColor }}
>
{/* Industry Badge */}
{company.industry && (
<div className="flex justify-center">
<span
className="text-xs font-semibold px-3 py-1.5 rounded-full"
style={{
backgroundColor: `${designMD.primaryColor}30`,
color: designMD.primaryColor,
border: `1px solid ${designMD.primaryColor}40`,
}}
>
{company.industry}
</span>
</div>
)}
{/* Social Links */}
{socialEntries.length > 0 && (
<div className="space-y-2">
{socialEntries.map((entry, i) => (
<div
key={i}
className="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
style={{
backgroundColor: `${designMD.primaryColor}10`,
}}
>
<span style={{ color: designMD.primaryColor }}>{entry.icon}</span>
<span
className="text-sm font-medium truncate"
style={{
color: designMD.textColor,
fontFamily: designMD.baseFont,
}}
>
{entry.value}
</span>
</div>
))}
</div>
)}
{/* Empty state */}
{!company.tagline && socialEntries.length === 0 && !company.industry && (
<p className="text-center text-sm opacity-50" style={{ color: designMD.textColor }}>
Completa los datos en el panel izquierdo para verlos aquí
</p>
)}
</div>
</div>
);
};
@@ -0,0 +1,771 @@
import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { Player } from '@remotion/player';
import {
AbsoluteFill,
Sequence,
Video,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from 'remotion';
import { DesignMD, CompanyProfile } from '../../../types';
import { CanvasWorkspace } from '../../ui/CanvasWorkspace';
interface PreviewRemotionProps {
designMD: DesignMD;
company: CompanyProfile;
aspectRatio?: '16:9' | '1:1' | '9:16';
onDesignChange?: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
focusSegment?: 'intro' | 'content' | 'outro' | 'audio' | null;
/** Called on every frame update with the current frame number */
onFrameUpdate?: (frame: number) => void;
/** Called when player is ready, passes a seek function */
onPlayerReady?: (seekFn: (frame: number) => void) => void;
}
const COMPOSITION_DIMS: Record<string, { width: number; height: number; css: string }> = {
'16:9': { width: 1920, height: 1080, css: '16/9' },
'1:1': { width: 1080, height: 1080, css: '1/1' },
'9:16': { width: 1080, height: 1920, css: '9/16' },
};
type DragElement = 'logo' | 'content' | 'intro' | 'outro'
| 'intro-resize-br' | 'intro-resize-bl' | 'intro-resize-tr' | 'intro-resize-tl'
| 'outro-resize-br' | 'outro-resize-bl' | 'outro-resize-tr' | 'outro-resize-tl'
| null;
/** Parse a CSS object-position string to x/y percentages */
function parseVideoPosition(pos?: string): { x: number; y: number } {
if (!pos) return { x: 50, y: 50 };
if (pos.includes('%')) {
const parts = pos.split(/\s+/);
return { x: parseFloat(parts[0]) || 50, y: parseFloat(parts[1]) || 50 };
}
const map: Record<string, { x: number; y: number }> = {
'top left': { x: 0, y: 0 }, 'top center': { x: 50, y: 0 }, 'top right': { x: 100, y: 0 }, 'top': { x: 50, y: 0 },
'center left': { x: 0, y: 50 }, 'center': { x: 50, y: 50 }, 'center right': { x: 100, y: 50 },
'bottom left': { x: 0, y: 100 }, 'bottom center': { x: 50, y: 100 }, 'bottom right': { x: 100, y: 100 }, 'bottom': { x: 50, y: 100 },
'left': { x: 0, y: 50 }, 'right': { x: 100, y: 50 },
};
return map[pos] || { x: 50, y: 50 };
}
/**
* Live Remotion Player showing a sample composition with the brand's DesignMD settings.
* Supports interactive drag-to-reposition for logo and content block.
*/
export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, company, aspectRatio = '9:16', onDesignChange, focusSegment, onFrameUpdate, onPlayerReady }) => {
const hasIntro = !!designMD.introVideoUrl;
const hasOutro = !!designMD.outroVideoUrl;
const introDur = designMD.introDurationFrames || 60;
const outroDur = designMD.outroDurationFrames || 60;
const contentDur = 180;
const totalDur = (hasIntro ? introDur : 0) + contentDur + (hasOutro ? outroDur : 0);
const dims = COMPOSITION_DIMS[aspectRatio] || COMPOSITION_DIMS['9:16'];
// Compute frame ranges for each segment
const introStart = 0;
const contentStart = hasIntro ? introDur : 0;
const outroStart = contentStart + contentDur;
// Player ref for seeking
const playerRef = useRef<any>(null);
// Drag state for the overlay
const overlayRef = useRef<HTMLDivElement>(null);
const [dragElement, setDragElement] = useState<DragElement>(null);
const [dragStart, setDragStart] = useState<{ x: number; y: number; origX: number; origY: number } | null>(null);
// Current positions
const logoX = designMD.logoX ?? 10;
const logoY = designMD.logoY ?? 5;
const contentX = designMD.contentX ?? 50;
const contentY = designMD.contentY ?? 75;
// Video box positions & sizes (% of canvas)
const introX = designMD.introVideoX ?? 0;
const introY = designMD.introVideoY ?? 0;
const introW = designMD.introVideoW ?? 100;
const introH = designMD.introVideoH ?? 100;
const outroX = designMD.outroVideoX ?? 0;
const outroY = designMD.outroVideoY ?? 0;
const outroW = designMD.outroVideoW ?? 100;
const outroH = designMD.outroVideoH ?? 100;
const getOrigForElement = useCallback((element: DragElement) => {
switch (element) {
case 'logo': return { x: logoX, y: logoY };
case 'content': return { x: contentX, y: contentY };
case 'intro': return { x: introX, y: introY };
case 'outro': return { x: outroX, y: outroY };
default: return { x: 50, y: 50 };
}
}, [logoX, logoY, contentX, contentY, introX, introY, outroX, outroY]);
const handlePointerDown = useCallback((e: React.PointerEvent, element: DragElement) => {
if (!onDesignChange) return;
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDragElement(element);
const orig = getOrigForElement(element);
setDragStart({
x: e.clientX,
y: e.clientY,
origX: orig.x,
origY: orig.y,
});
}, [onDesignChange, getOrigForElement]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragElement || !dragStart || !overlayRef.current || !onDesignChange) return;
const rect = overlayRef.current.getBoundingClientRect();
const deltaXPct = ((e.clientX - dragStart.x) / rect.width) * 100;
const deltaYPct = ((e.clientY - dragStart.y) / rect.height) * 100;
// No clamping — allow elements to extend beyond canvas boundaries
const newX = Math.round(dragStart.origX + deltaXPct);
const newY = Math.round(dragStart.origY + deltaYPct);
if (dragElement === 'logo') {
onDesignChange('logoX', newX);
onDesignChange('logoY', newY);
} else if (dragElement === 'content') {
onDesignChange('contentX', newX);
onDesignChange('contentY', newY);
} else if (dragElement === 'intro') {
onDesignChange('introVideoX', newX);
onDesignChange('introVideoY', newY);
} else if (dragElement === 'outro') {
onDesignChange('outroVideoX', newX);
onDesignChange('outroVideoY', newY);
} else if (dragElement?.startsWith('intro-resize-')) {
const corner = dragElement.replace('intro-resize-', '');
if (corner === 'br') {
onDesignChange('introVideoW', Math.max(10, Math.round(introW + deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH + deltaYPct)));
} else if (corner === 'bl') {
onDesignChange('introVideoX', Math.round(introX + deltaXPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW - deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH + deltaYPct)));
} else if (corner === 'tr') {
onDesignChange('introVideoY', Math.round(introY + deltaYPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW + deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH - deltaYPct)));
} else if (corner === 'tl') {
onDesignChange('introVideoX', Math.round(introX + deltaXPct));
onDesignChange('introVideoY', Math.round(introY + deltaYPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW - deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH - deltaYPct)));
}
setDragStart({ ...dragStart, x: e.clientX, y: e.clientY });
} else if (dragElement?.startsWith('outro-resize-')) {
const corner = dragElement.replace('outro-resize-', '');
if (corner === 'br') {
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW + deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH + deltaYPct)));
} else if (corner === 'bl') {
onDesignChange('outroVideoX', Math.round(outroX + deltaXPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW - deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH + deltaYPct)));
} else if (corner === 'tr') {
onDesignChange('outroVideoY', Math.round(outroY + deltaYPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW + deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH - deltaYPct)));
} else if (corner === 'tl') {
onDesignChange('outroVideoX', Math.round(outroX + deltaXPct));
onDesignChange('outroVideoY', Math.round(outroY + deltaYPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW - deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH - deltaYPct)));
}
setDragStart({ ...dragStart, x: e.clientX, y: e.clientY });
}
}, [dragElement, dragStart, onDesignChange, introX, introY, introW, introH, outroX, outroY, outroW, outroH]);
const handlePointerUp = useCallback(() => {
setDragElement(null);
setDragStart(null);
}, []);
// Seek player to the focused segment when it changes
useEffect(() => {
if (!playerRef.current || !focusSegment) return;
const player = playerRef.current;
try {
player.pause();
let targetFrame = 0;
if (focusSegment === 'intro') targetFrame = introStart;
else if (focusSegment === 'content') targetFrame = contentStart;
else if (focusSegment === 'outro') targetFrame = outroStart;
player.seekTo(targetFrame);
} catch {
// Player may not be ready yet
}
}, [focusSegment, introStart, contentStart, outroStart]);
// Expose seek function to parent
useEffect(() => {
if (!playerRef.current || !onPlayerReady) return;
const player = playerRef.current;
onPlayerReady((frame: number) => {
try {
player.pause();
player.seekTo(frame);
} catch { /* noop */ }
});
}, [onPlayerReady]);
// Subscribe to frame updates
useEffect(() => {
if (!playerRef.current || !onFrameUpdate) return;
const player = playerRef.current;
const handler = (e: { detail: { frame: number } }) => {
onFrameUpdate(e.detail.frame);
};
player.addEventListener('frameupdate', handler);
return () => player.removeEventListener('frameupdate', handler);
}, [onFrameUpdate]);
const inputProps = useMemo(() => ({
designMD,
company,
introDur,
outroDur,
contentDur,
hasIntro,
hasOutro,
}), [designMD, company, introDur, outroDur, contentDur, hasIntro, hasOutro]);
// Whether we're in editing mode (a segment is focused)
const isEditing = !!focusSegment && focusSegment !== 'audio';
return (
<div className="flex flex-col items-center h-full max-h-full">
<CanvasWorkspace
aspectRatio={dims.css}
isEditing={isEditing}
canvasClassName="rounded-2xl shadow-2xl border border-neutral-800 bg-neutral-900"
overlayRef={overlayRef}
overlayPointerEvents={!!dragElement}
onOverlayPointerMove={handlePointerMove}
onOverlayPointerUp={handlePointerUp}
overlay={onDesignChange ? (
<>
{/* Logo drag handle — only when content segment is selected */}
{focusSegment === 'content' && (
<div
className={`absolute cursor-grab active:cursor-grabbing transition-all ${
dragElement === 'logo' ? 'z-20 scale-110' : 'hover:ring-2 hover:ring-violet-400/40 hover:ring-offset-2 hover:ring-offset-transparent'
}`}
style={{
left: `${logoX}%`,
top: `${logoY}%`,
pointerEvents: 'auto',
padding: '8px',
borderRadius: '8px',
}}
onPointerDown={(e) => handlePointerDown(e, 'logo')}
title="Arrastra para mover el logo"
>
<div className="bg-violet-500/10 border border-violet-500/30 rounded-lg px-3 py-1.5 backdrop-blur-sm">
<span className="text-[9px] font-bold text-violet-300 uppercase tracking-wider">Logo</span>
</div>
</div>
)}
{/* Content block drag handle — only when content segment is selected */}
{focusSegment === 'content' && (
<div
className={`absolute cursor-grab active:cursor-grabbing transition-all -translate-x-1/2 ${
dragElement === 'content' ? 'z-20 scale-110' : 'hover:ring-2 hover:ring-amber-400/40 hover:ring-offset-2 hover:ring-offset-transparent'
}`}
style={{
left: `${contentX}%`,
top: `${contentY}%`,
pointerEvents: 'auto',
padding: '8px',
borderRadius: '8px',
}}
onPointerDown={(e) => handlePointerDown(e, 'content')}
title="Arrastra para mover el bloque de texto"
>
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-1.5 backdrop-blur-sm">
<span className="text-[9px] font-bold text-amber-300 uppercase tracking-wider">Texto</span>
</div>
</div>
)}
{/* Intro video box — only when intro is selected */}
{hasIntro && focusSegment === 'intro' && (
<VideoBoxHandle
label="Intro"
color="emerald"
x={introX}
y={introY}
w={introW}
h={introH}
isDragging={dragElement === 'intro' || !!dragElement?.startsWith('intro-resize')}
onMoveDown={(e) => handlePointerDown(e, 'intro')}
onResizeDown={(e, corner) => handlePointerDown(e, `intro-resize-${corner}` as DragElement)}
/>
)}
{/* Outro video box — only when outro is selected */}
{hasOutro && focusSegment === 'outro' && (
<VideoBoxHandle
label="Outro"
color="rose"
x={outroX}
y={outroY}
w={outroW}
h={outroH}
isDragging={dragElement === 'outro' || !!dragElement?.startsWith('outro-resize')}
onMoveDown={(e) => handlePointerDown(e, 'outro')}
onResizeDown={(e, corner) => handlePointerDown(e, `outro-resize-${corner}` as DragElement)}
/>
)}
</>
) : undefined}
>
<Player
ref={playerRef}
component={SampleComposition}
inputProps={inputProps}
durationInFrames={Math.max(totalDur, 60)}
compositionWidth={dims.width}
compositionHeight={dims.height}
fps={30}
controls
loop={!focusSegment || focusSegment === 'audio'}
autoPlay={!focusSegment || focusSegment === 'audio'}
style={{
width: '100%',
height: '100%',
}}
/>
</CanvasWorkspace>
{/* Info bar */}
<div className="flex items-center gap-3 mt-3 shrink-0">
<span className="text-[10px] font-mono text-neutral-500">
{(totalDur / 30).toFixed(1)}s · {aspectRatio} · {dims.width}×{dims.height} · 30fps
</span>
<div className="flex gap-1.5">
{hasIntro && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 border border-violet-500/20">
INTRO
</span>
)}
<span className="text-[9px] px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-400 border border-neutral-700">
CONTENIDO
</span>
{hasOutro && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 border border-violet-500/20">
OUTRO
</span>
)}
</div>
{onDesignChange && (
<span className="text-[9px] text-violet-400/50 ml-1">
Drag handles para posicionar
</span>
)}
</div>
</div>
);
};
// ═══ Sample Remotion Composition ═══
interface SampleProps {
designMD: DesignMD;
company: CompanyProfile;
introDur: number;
outroDur: number;
contentDur: number;
hasIntro: boolean;
hasOutro: boolean;
}
const SampleComposition: React.FC<SampleProps> = ({
designMD,
company,
introDur,
outroDur,
contentDur,
hasIntro,
hasOutro,
}) => {
const contentStart = hasIntro ? introDur : 0;
const outroStart = contentStart + contentDur;
return (
<AbsoluteFill style={{ backgroundColor: designMD.secondaryColor }}>
{/* Brand Frame — always visible */}
<AbsoluteFill
style={{
border: `${designMD.frameThickness}px solid ${designMD.primaryColor}`,
boxSizing: 'border-box',
zIndex: 10,
pointerEvents: 'none',
}}
/>
{/* ── INTRO SEQUENCE ── */}
{hasIntro && (
<Sequence from={0} durationInFrames={introDur} name="Intro">
<IntroSection designMD={designMD} company={company} />
</Sequence>
)}
{/* ── CONTENT SEQUENCE ── */}
<Sequence from={contentStart} durationInFrames={contentDur} name="Content">
<ContentSection designMD={designMD} company={company} />
</Sequence>
{/* ── OUTRO SEQUENCE ── */}
{hasOutro && (
<Sequence from={outroStart} durationInFrames={outroDur} name="Outro">
<OutroSection designMD={designMD} company={company} />
</Sequence>
)}
</AbsoluteFill>
);
};
// ═══ INTRO ═══
const IntroSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (designMD.introVideoUrl) {
const vx = designMD.introVideoX ?? 0;
const vy = designMD.introVideoY ?? 0;
const vw = designMD.introVideoW ?? 100;
const vh = designMD.introVideoH ?? 100;
return (
<AbsoluteFill>
<div style={{
position: 'absolute',
left: `${vx}%`, top: `${vy}%`,
width: `${vw}%`, height: `${vh}%`,
overflow: 'hidden',
borderRadius: vw < 100 || vh < 100 ? 8 : 0,
}}>
<Video
src={designMD.introVideoUrl}
style={{
width: '100%',
height: '100%',
objectFit: (designMD.introVideoFit || 'cover') as React.CSSProperties['objectFit'],
}}
volume={0}
/>
</div>
{/* Logo overlay on intro video */}
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${designMD.logoX ?? 5}%`,
top: `${designMD.logoY ?? 5}%`,
opacity: interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' }),
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
</AbsoluteFill>
);
}
// Fallback placeholder intro
const scale = spring({ frame, fps, config: { damping: 12, stiffness: 80 } });
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: designMD.primaryColor }}>
<div style={{ transform: `scale(${scale})`, textAlign: 'center' }}>
{designMD.logoUrl && (
<img src={designMD.logoUrl} alt="" style={{ width: 240, margin: '0 auto 24px', objectFit: 'contain' }} />
)}
<h1 style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.textColor,
fontSize: 72,
fontWeight: 'bold',
}}>
{company.name || 'INTRO'}
</h1>
</div>
</AbsoluteFill>
);
};
// ═══ CONTENT ═══
const ContentSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const transitionIn = designMD.defaultTransitionIn || 'fade';
const transitionOut = designMD.defaultTransitionOut || 'none';
const entryStyle = getTransitionStyle(transitionIn, frame, fps, 'in');
const exitFrame = durationInFrames - frame;
const exitStyle = transitionOut !== 'none' ? getTransitionStyle(transitionOut, exitFrame, fps, 'out') : {};
const combinedTextStyle = frame < 25 ? entryStyle : exitFrame < 25 ? exitStyle : {};
// Use freeform positions if set, otherwise fall back to preset
const logoX = designMD.logoX ?? 5;
const logoY = designMD.logoY ?? 5;
const contentX = designMD.contentX ?? 50;
const contentY = designMD.contentY ?? 75;
return (
<AbsoluteFill
style={{
padding: `${designMD.frameThickness + 40}px`,
}}
>
{/* Logo — freeform positioned */}
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${logoX}%`,
top: `${logoY}%`,
...combinedTextStyle,
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
{/* Text block — freeform positioned */}
<div
style={{
position: 'absolute',
left: `${contentX}%`,
top: `${contentY}%`,
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(8px)',
padding: '48px 36px',
borderRadius: 24,
textAlign: 'center',
maxWidth: '80%',
...combinedTextStyle,
}}
>
<h1 style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.titleColor || designMD.textColor,
fontSize: designMD.titleSize || 64,
fontWeight: 'bold',
lineHeight: 1.1,
margin: 0,
}}>
{company.name || 'Tu Marca'}
</h1>
{company.tagline && (
<p style={{
fontFamily: designMD.subtitleFont || designMD.baseFont,
color: designMD.subtitleColor || designMD.textColor,
fontSize: designMD.subtitleSize || 32,
marginTop: 16,
opacity: 0.9,
lineHeight: 1.3,
}}>
{company.tagline}
</p>
)}
{company.socialLinks?.instagram && (
<p style={{
fontFamily: designMD.paragraphFont || designMD.baseFont,
color: designMD.primaryColor,
fontSize: 28,
marginTop: 24,
opacity: interpolate(frame, [30, 45], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
}}>
{company.socialLinks.instagram}
</p>
)}
</div>
</AbsoluteFill>
);
};
// ═══ OUTRO ═══
const OutroSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
if (designMD.outroVideoUrl) {
const vx = designMD.outroVideoX ?? 0;
const vy = designMD.outroVideoY ?? 0;
const vw = designMD.outroVideoW ?? 100;
const vh = designMD.outroVideoH ?? 100;
return (
<AbsoluteFill>
<div style={{
position: 'absolute',
left: `${vx}%`, top: `${vy}%`,
width: `${vw}%`, height: `${vh}%`,
overflow: 'hidden',
borderRadius: vw < 100 || vh < 100 ? 8 : 0,
}}>
<Video
src={designMD.outroVideoUrl}
style={{
width: '100%',
height: '100%',
objectFit: (designMD.outroVideoFit || 'cover') as React.CSSProperties['objectFit'],
}}
volume={0}
/>
</div>
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${designMD.logoX ?? 5}%`,
top: `${designMD.logoY ?? 5}%`,
opacity: interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' }),
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
</AbsoluteFill>
);
}
// Fallback placeholder outro
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' });
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: designMD.primaryColor, opacity }}>
<div style={{ textAlign: 'center' }}>
{designMD.logoUrl && (
<img src={designMD.logoUrl} alt="" style={{ width: 180, margin: '0 auto 20px', objectFit: 'contain' }} />
)}
<p style={{
fontFamily: designMD.baseFont,
color: designMD.textColor,
fontSize: 36,
opacity: 0.8,
}}>
{company.socialLinks?.website || company.socialLinks?.instagram || company.name}
</p>
</div>
</AbsoluteFill>
);
};
// ═══ HELPERS ═══
function getTransitionStyle(
type: string,
frame: number,
fps: number,
direction: 'in' | 'out'
): React.CSSProperties {
const progress = Math.min(frame / 18, 1);
switch (type) {
case 'fade':
return { opacity: progress };
case 'slideUp':
return { opacity: progress, transform: `translateY(${(1 - progress) * 80}px)` };
case 'slideRight':
return { opacity: progress, transform: `translateX(${(progress - 1) * 100}px)` };
case 'bounce': {
const s = spring({ frame, fps, config: { damping: 8, stiffness: 120 } });
return { transform: `scale(${s})` };
}
case 'scale':
return { opacity: progress, transform: `scale(${0.4 + progress * 0.6})` };
case 'typewriter':
return { opacity: Math.round(progress * 4) / 4 };
default:
return {};
}
}
// ═══ VideoBoxHandle — resizable rectangle with corner handles ═══
const CORNER_CURSORS: Record<string, string> = {
tl: 'nwse-resize', tr: 'nesw-resize',
bl: 'nesw-resize', br: 'nwse-resize',
};
interface VideoBoxHandleProps {
label: string;
color: 'emerald' | 'rose';
x: number; y: number; w: number; h: number;
isDragging: boolean;
onMoveDown: (e: React.PointerEvent) => void;
onResizeDown: (e: React.PointerEvent, corner: string) => void;
}
const COLOR_MAP = {
emerald: { border: '#10b981', bg: 'rgba(16,185,129,0.08)', text: '#6ee7b7', label: 'rgba(16,185,129,0.15)' },
rose: { border: '#f43f5e', bg: 'rgba(244,63,94,0.08)', text: '#fda4af', label: 'rgba(244,63,94,0.15)' },
};
const VideoBoxHandle: React.FC<VideoBoxHandleProps> = ({ label, color, x, y, w, h, isDragging, onMoveDown, onResizeDown }) => {
const c = COLOR_MAP[color];
return (
<div
className="absolute"
style={{
left: `${x}%`, top: `${y}%`,
width: `${w}%`, height: `${h}%`,
pointerEvents: 'auto',
zIndex: isDragging ? 30 : 10,
}}
>
{/* Border + background */}
<div
className="absolute inset-0 cursor-grab active:cursor-grabbing"
style={{
border: `2px ${isDragging ? 'solid' : 'dashed'} ${c.border}`,
borderRadius: '8px',
background: c.bg,
}}
onPointerDown={onMoveDown}
title={`Arrastra para mover ${label}`}
>
{/* Label */}
<div
className="absolute top-2 left-2 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider backdrop-blur-sm"
style={{ background: c.label, color: c.text, border: `1px solid ${c.border}40` }}
>
{label}
</div>
{/* Size info */}
<div
className="absolute bottom-1 right-2 text-[8px] font-mono opacity-60"
style={{ color: c.text }}
>
{w}% × {h}%
</div>
</div>
{/* Corner resize handles */}
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => (
<div
key={corner}
className="absolute w-3 h-3 rounded-full border-2 bg-neutral-950"
style={{
borderColor: c.border,
cursor: CORNER_CURSORS[corner],
...(corner.includes('t') ? { top: -6 } : { bottom: -6 }),
...(corner.includes('l') ? { left: -6 } : { right: -6 }),
pointerEvents: 'auto',
zIndex: 40,
}}
onPointerDown={(e) => onResizeDown(e, corner)}
title={`Redimensionar ${label}`}
/>
))}
</div>
);
};
@@ -0,0 +1,180 @@
import React from 'react';
import { Film, Volume2, Monitor, Square, Smartphone } from 'lucide-react';
import { DesignMD } from '../../../types';
interface PreviewTimelineProps {
designMD: DesignMD;
aspectRatio?: '16:9' | '1:1' | '9:16';
}
const RATIO_INFO: Record<string, { icon: React.ReactNode; res: string; label: string }> = {
'16:9': { icon: <Monitor size={12} />, res: '1920×1080', label: 'Landscape' },
'1:1': { icon: <Square size={12} />, res: '1080×1080', label: 'Cuadrado' },
'9:16': { icon: <Smartphone size={12} />, res: '1080×1920', label: 'Vertical' },
};
/**
* Visual timeline mockup showing the video structure:
* intro → transition → content → transition → outro + audio status.
*/
export const PreviewTimeline: React.FC<PreviewTimelineProps> = ({ designMD, aspectRatio = '9:16' }) => {
const hasIntro = !!designMD.introVideoUrl;
const hasOutro = !!designMD.outroVideoUrl;
const hasAudio = !!designMD.brandAudioUrl;
const introDur = designMD.introDurationFrames || 60;
const outroDur = designMD.outroDurationFrames || 60;
const totalDur = (hasIntro ? introDur : 0) + (hasOutro ? outroDur : 0) || 1;
return (
<div className="w-full max-w-lg space-y-4">
{/* Timeline Blocks */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-[10px] font-mono uppercase tracking-widest text-neutral-500">Estructura del Video</h4>
<span className="text-[10px] font-mono text-neutral-600 flex items-center gap-1.5 bg-neutral-800/50 px-2 py-1 rounded-md">
{RATIO_INFO[aspectRatio]?.icon}
{aspectRatio} · {RATIO_INFO[aspectRatio]?.res}
</span>
</div>
{/* Timeline visual */}
<div className="flex items-center gap-1.5">
{/* Intro */}
{hasIntro && (
<TimelineBlock
label="INTRO"
icon={<Film size={14} />}
duration={introDur}
color={designMD.primaryColor}
widthPercent={(introDur / totalDur) * 100}
/>
)}
{/* Outro */}
{hasOutro && (
<TimelineBlock
label="OUTRO"
icon={<Film size={14} />}
duration={outroDur}
color={designMD.primaryColor}
widthPercent={(outroDur / totalDur) * 100}
/>
)}
</div>
{/* Duration */}
<div className="flex justify-between text-[10px] font-mono text-neutral-500">
<span>0:00</span>
<span>{(totalDur / 30).toFixed(1)}s · {RATIO_INFO[aspectRatio]?.label} · 30fps</span>
</div>
</div>
{/* Audio Status */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-6 space-y-3">
<h4 className="text-[10px] font-mono uppercase tracking-widest text-neutral-500">Audio de Marca</h4>
{hasAudio ? (
<div className="space-y-3">
{/* Audio waveform mockup */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-violet-500/20 border border-violet-500/30 flex items-center justify-center">
<Volume2 size={18} className="text-violet-400" />
</div>
<div className="flex-1">
<div className="flex items-end gap-[2px] h-6">
{Array.from({ length: 32 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-full bg-violet-500/60"
style={{
height: `${Math.max(2, Math.sin(i * 0.4) * 16 + Math.random() * 8 + 4)}px`,
opacity: getWaveformOpacity(i, 32, designMD),
}}
/>
))}
</div>
<p className="text-[10px] text-neutral-500 mt-1 font-mono truncate">
{designMD.brandAudioUrl?.split('/').pop() || 'audio.mp3'}
</p>
</div>
<span className="text-xs font-mono text-violet-300 bg-neutral-800 px-2 py-1 rounded">
{Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}%
</span>
</div>
{/* Fade indicators */}
<div className="flex gap-4">
{designMD.autoFadeInAudio && (
<span className="text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-1 rounded-md">
Fade-In {((designMD.audioFadeInDuration || 15) / 30).toFixed(1)}s
</span>
)}
{designMD.autoFadeOutAudio && (
<span className="text-[10px] text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-1 rounded-md">
Fade-Out {((designMD.audioFadeOutDuration || 15) / 30).toFixed(1)}s
</span>
)}
{!designMD.autoFadeInAudio && !designMD.autoFadeOutAudio && (
<span className="text-[10px] text-neutral-500">Sin fade automático</span>
)}
</div>
</div>
) : (
<div className="text-center py-4">
<Volume2 size={24} className="mx-auto text-neutral-700 mb-2" />
<p className="text-xs text-neutral-500">Sin audio de marca configurado</p>
</div>
)}
</div>
</div>
);
};
// ─── Sub-components ───
const TimelineBlock: React.FC<{
label: string;
icon: React.ReactNode;
duration: number;
color: string;
widthPercent: number;
isMain?: boolean;
}> = ({ label, icon, duration, color, widthPercent, isMain }) => (
<div
className="rounded-lg p-3 flex flex-col items-center justify-center text-center transition-all"
style={{
backgroundColor: `${color}${isMain ? '30' : '50'}`,
border: `1px solid ${color}60`,
flex: `${widthPercent} 0 0`,
minWidth: '70px',
}}
>
<span style={{ color }} className="mb-1">{icon}</span>
<span className="text-[9px] font-bold tracking-wider text-white opacity-80">{label}</span>
<span className="text-[9px] font-mono text-neutral-400 mt-0.5">{(duration / 30).toFixed(1)}s</span>
</div>
);
function getWaveformOpacity(i: number, total: number, designMD: DesignMD): number {
let opacity = 1;
const fadeInFrames = designMD.audioFadeInDuration || 15;
const fadeOutFrames = designMD.audioFadeOutDuration || 15;
const fadeInBars = Math.ceil((fadeInFrames / 300) * total);
const fadeOutBars = Math.ceil((fadeOutFrames / 300) * total);
if (designMD.autoFadeInAudio && i < fadeInBars) {
opacity = i / fadeInBars;
}
if (designMD.autoFadeOutAudio && i > total - fadeOutBars) {
opacity = (total - i) / fadeOutBars;
}
return Math.max(0.1, opacity);
}
@@ -0,0 +1,120 @@
import React from 'react';
import { DesignMD } from '../../../types';
interface PreviewTypographyProps {
designMD: DesignMD;
}
/**
* Isolated typography hierarchy preview showing all text levels
* with their real fonts, sizes, and colors.
*/
export const PreviewTypography: React.FC<PreviewTypographyProps> = ({ designMD }) => {
const titleFont = designMD.titleFont || designMD.baseFont;
const subtitleFont = designMD.subtitleFont || designMD.baseFont;
const paragraphFont = designMD.paragraphFont || designMD.baseFont;
const baseFont = designMD.baseFont;
const titleSize = designMD.titleSize || 64;
const subtitleSize = designMD.subtitleSize || 32;
const paragraphSize = designMD.paragraphSize || 16;
const titleColor = designMD.titleColor || designMD.textColor;
const subtitleColor = designMD.subtitleColor || designMD.textColor;
const paragraphColor = designMD.paragraphColor || designMD.textColor;
return (
<div
className="w-[420px] rounded-2xl overflow-hidden shadow-2xl"
style={{
backgroundColor: designMD.secondaryColor,
border: `${designMD.frameThickness}px solid ${designMD.primaryColor}`,
}}
>
<div className="p-8 space-y-6">
{/* Heading 1 */}
<div className="space-y-1">
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40" style={{ color: designMD.textColor }}>
Título {titleFont.split(',')[0].replace(/"/g, '')} · {titleSize}px
</span>
<h1
style={{
fontFamily: titleFont,
fontSize: `${Math.min(titleSize, 56)}px`,
color: titleColor,
lineHeight: 1.1,
}}
className="font-bold tracking-tight"
>
Título Principal
</h1>
<div className="h-px mt-2" style={{ backgroundColor: `${designMD.primaryColor}30` }} />
</div>
{/* Heading 2 */}
<div className="space-y-1">
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40" style={{ color: designMD.textColor }}>
Subtítulo {subtitleFont.split(',')[0].replace(/"/g, '')} · {subtitleSize}px
</span>
<h2
style={{
fontFamily: subtitleFont,
fontSize: `${Math.min(subtitleSize, 32)}px`,
color: subtitleColor,
lineHeight: 1.2,
}}
className="font-semibold"
>
Subtítulo de Sección
</h2>
<div className="h-px mt-2" style={{ backgroundColor: `${designMD.primaryColor}20` }} />
</div>
{/* Paragraph */}
<div className="space-y-1">
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40" style={{ color: designMD.textColor }}>
Párrafo {paragraphFont.split(',')[0].replace(/"/g, '')} · {paragraphSize}px
</span>
<p
style={{
fontFamily: paragraphFont,
fontSize: `${Math.min(paragraphSize, 18)}px`,
color: paragraphColor,
lineHeight: 1.6,
}}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
{/* Glyph Preview */}
<div
className="rounded-xl p-5 text-center space-y-2"
style={{ backgroundColor: `${designMD.primaryColor}10` }}
>
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40 block" style={{ color: designMD.textColor }}>
Glifos {baseFont.split(',')[0].replace(/"/g, '')}
</span>
<p
className="text-2xl font-bold tracking-wider"
style={{ fontFamily: baseFont, color: titleColor }}
>
ABCDEFGHIJKLM
</p>
<p
className="text-2xl tracking-wider"
style={{ fontFamily: baseFont, color: subtitleColor }}
>
abcdefghijklm
</p>
<p
className="text-xl font-mono tracking-[0.3em]"
style={{ fontFamily: baseFont, color: paragraphColor, opacity: 0.7 }}
>
0123456789
</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Subtitles, Loader2, X } from 'lucide-react';
import { CAPTION_PRESETS, CaptionStyle, DEFAULT_CAPTION_STYLE } from '../../utils/captionGenerator';
interface CaptionStylePickerProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (style: CaptionStyle) => void;
isLoading: boolean;
}
/**
* CaptionStylePicker — Modal for choosing caption style before generating auto-captions.
*/
export const CaptionStylePicker: React.FC<CaptionStylePickerProps> = ({
isOpen,
onClose,
onGenerate,
isLoading,
}) => {
const [selectedPreset, setSelectedPreset] = useState(0);
if (!isOpen) return null;
const currentStyle = CAPTION_PRESETS[selectedPreset]?.style ?? DEFAULT_CAPTION_STYLE;
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-neutral-900 border border-neutral-700 rounded-2xl w-[420px] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Subtitles size={18} className="text-amber-400" />
<h3 className="text-sm font-bold text-white">Auto-Captions</h3>
</div>
<button
onClick={onClose}
title="Cerrar"
className="p-1 rounded hover:bg-neutral-800 text-neutral-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Style Presets */}
<div className="p-5 space-y-4">
<label className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Estilo de Subtítulos</label>
<div className="grid grid-cols-2 gap-2">
{CAPTION_PRESETS.map((preset, idx) => (
<button
key={preset.name}
onClick={() => setSelectedPreset(idx)}
title={`Estilo ${preset.name}`}
className={`p-3 rounded-xl border text-left transition-all ${
selectedPreset === idx
? 'border-violet-500/50 bg-violet-500/10 ring-1 ring-violet-500/20'
: 'border-neutral-800 bg-neutral-950/50 hover:border-neutral-700'
}`}
>
<span className="text-xs font-medium text-white">{preset.name}</span>
{/* Preview */}
<div
className="mt-2 px-2 py-1 rounded text-center text-[10px] leading-snug"
style={{
color: preset.style.color,
background: preset.style.backgroundColor || 'transparent',
fontSize: '11px',
fontWeight: 700,
}}
>
Hola, esto es un ejemplo
</div>
<div className="mt-1.5 text-[9px] text-neutral-500">
{preset.style.fontSize}px · {preset.style.position} · {preset.style.maxWordsPerGroup} palabras
</div>
</button>
))}
</div>
{/* Info */}
<div className="bg-neutral-950/50 rounded-lg p-3 border border-neutral-800/50">
<p className="text-[10px] text-neutral-400 leading-relaxed">
Se transcribirá el audio y se generarán subtítulos sincronizados palabra por palabra.
Los subtítulos se crearán como elementos de texto en una nueva capa.
</p>
</div>
</div>
{/* Actions */}
<div className="px-5 pb-5 flex gap-2">
<button
onClick={onClose}
title="Cancelar"
className="flex-1 py-2.5 rounded-xl border border-neutral-800 text-neutral-400 text-xs font-medium hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={() => onGenerate(currentStyle)}
disabled={isLoading}
title="Generar subtítulos automáticos"
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 text-white text-xs font-bold hover:from-amber-400 hover:to-orange-400 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 size={14} className="animate-spin" />
Transcribiendo...
</>
) : (
<>
<Subtitles size={14} />
Generar Captions
</>
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,61 @@
import React from 'react';
import { Sequence, AbsoluteFill, Img, Video } from 'remotion';
import { TimelineElement, TimelineLayer, MediaFilter } from '../../types';
const getFilterStyle = (filter?: MediaFilter): React.CSSProperties => {
switch (filter) {
case 'grayscale':
return { filter: 'grayscale(100%)' };
case 'sepia':
return { filter: 'sepia(100%)' };
case 'contrast':
return { filter: 'contrast(150%)' };
default:
return {};
}
};
interface BackgroundLayerProps {
timelineElements: TimelineElement[];
layers: TimelineLayer[];
}
export const BackgroundLayer: React.FC<BackgroundLayerProps> = ({ timelineElements, layers }) => {
const backgroundElements = timelineElements.filter(
el => layers?.find(l => l.id === el.layerId)?.type === 'background'
);
return (
<>
{backgroundElements.map((el) => {
const filterStyle = getFilterStyle(el.filter || 'none');
return (
<Sequence key={el.id} from={el.startFrame} durationInFrames={el.endFrame - el.startFrame}>
<AbsoluteFill style={filterStyle}>
{el.type === 'color' && (
<div style={{
width: '100%',
height: '100%',
...(el.content.includes('gradient')
? { background: el.content }
: { backgroundColor: el.content }),
}}>
{el.backgroundPattern && (
<div style={{
position: 'absolute',
inset: 0,
backgroundImage: el.backgroundPattern,
backgroundSize: '10px 10px',
}} />
)}
</div>
)}
{el.type === 'image' && <Img src={el.content} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
{el.type === 'video' && <Video src={el.content} style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={(e) => console.warn('Video failed to load', e)} />}
</AbsoluteFill>
</Sequence>
);
})}
</>
);
};
@@ -0,0 +1,57 @@
import React from 'react';
import { AbsoluteFill } from 'remotion';
import { DesignMD } from '../../types';
interface BrandOverlayProps {
designMD: DesignMD;
textOverlay: string;
brandVisibility?: { logo: boolean; frame: boolean };
}
export const BrandOverlay: React.FC<BrandOverlayProps> = ({ designMD, textOverlay, brandVisibility }) => {
const showLogo = brandVisibility?.logo ?? true;
const showFrame = brandVisibility?.frame ?? true;
return (
<AbsoluteFill
style={{
border: showFrame ? `${designMD.frameThickness}px solid ${designMD.primaryColor}` : 'none',
boxSizing: 'border-box',
padding: '40px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
pointerEvents: 'none',
}}
>
{/* Cabecera: Logo de la Marca */}
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
{showLogo && designMD.logoUrl && (
<img
src={designMD.logoUrl}
alt="Brand Logo"
style={{ width: '120px', objectFit: 'contain' }}
/>
)}
</div>
{/* Pie: Texto sin manipulación de la imagen original */}
<div
style={{
fontFamily: designMD.baseFont,
color: designMD.textColor,
fontSize: '48px',
fontWeight: 'bold',
textAlign: 'center',
textShadow: '2px 2px 4px rgba(0,0,0,0.8)',
backgroundColor: 'rgba(0,0,0,0.4)',
padding: '24px',
borderRadius: '16px',
backdropFilter: 'blur(4px)',
}}
>
{textOverlay}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,56 @@
import React from 'react';
interface CanvasGridOverlayProps {
visible: boolean;
cols?: number;
rows?: number;
}
/**
* CanvasGridOverlay — Renders a semi-transparent grid overlay on the canvas.
* Uses CSS repeating-linear-gradient for performance (no DOM nodes per line).
*/
export const CanvasGridOverlay: React.FC<CanvasGridOverlayProps> = ({
visible,
cols = 6,
rows = 6,
}) => {
if (!visible) return null;
const colWidth = 100 / cols;
const rowHeight = 100 / rows;
return (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 40,
pointerEvents: 'none',
backgroundImage: `
repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px, transparent 1px, transparent ${colWidth}%),
repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px, transparent 1px, transparent ${rowHeight}%)
`,
backgroundSize: `${colWidth}% ${rowHeight}%`,
}}
>
{/* Center crosshair */}
<div style={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: '1px',
backgroundColor: 'rgba(168,85,247,0.15)',
}} />
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: '1px',
backgroundColor: 'rgba(168,85,247,0.15)',
}} />
</div>
);
};
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
interface CanvasRulersProps {
width: number;
height: number;
zoom: number;
}
/**
* CanvasRulers — Horizontal and vertical pixel rulers on canvas edges.
* Shows tick marks every 100px with labels.
*/
export const CanvasRulers: React.FC<CanvasRulersProps> = ({ width, height, zoom }) => {
const step = 100; // pixels between major ticks
const hTicks = Math.ceil(width / step);
const vTicks = Math.ceil(height / step);
return (
<>
{/* Horizontal Ruler (top) */}
<div
style={{
position: 'absolute',
top: -16,
left: 0,
width: '100%',
height: 14,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 30,
}}
>
{Array.from({ length: hTicks + 1 }, (_, i) => (
<div
key={i}
style={{
position: 'absolute',
left: `${(i * step / width) * 100}%`,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<span
style={{
fontSize: 7,
fontFamily: 'monospace',
color: 'rgba(161, 161, 170, 0.4)',
userSelect: 'none',
lineHeight: 1,
}}
>
{i * step}
</span>
<div
style={{
width: 1,
height: 4,
backgroundColor: 'rgba(161, 161, 170, 0.25)',
}}
/>
</div>
))}
</div>
{/* Vertical Ruler (left) */}
<div
style={{
position: 'absolute',
top: 0,
left: -22,
width: 18,
height: '100%',
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 30,
}}
>
{Array.from({ length: vTicks + 1 }, (_, i) => (
<div
key={i}
style={{
position: 'absolute',
top: `${(i * step / height) * 100}%`,
right: 0,
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<span
style={{
fontSize: 7,
fontFamily: 'monospace',
color: 'rgba(161, 161, 170, 0.4)',
userSelect: 'none',
lineHeight: 1,
writingMode: 'vertical-lr',
transform: 'rotate(180deg)',
}}
>
{i * step}
</span>
<div
style={{
width: 4,
height: 1,
backgroundColor: 'rgba(161, 161, 170, 0.25)',
}}
/>
</div>
))}
</div>
</>
);
};
@@ -0,0 +1,146 @@
import React, { useRef, useEffect, useMemo } from 'react';
import {
applyChromaKey,
hexToRgb,
mapToleranceToDistance,
mapSoftnessToDistance,
} from '../../utils/chromaKeyUtils';
import { ChromaKeyShader } from '../../utils/ChromaKeyShader';
interface ChromaKeyImageProps {
src: string;
chromaKeyColor: string;
chromaKeyTolerance: number;
chromaKeySoftness: number;
style?: React.CSSProperties;
draggable?: boolean;
}
// Cache WebGL support check
let webglSupported: boolean | null = null;
function isWebGLSupported(): boolean {
if (webglSupported === null) {
webglSupported = ChromaKeyShader.isSupported();
}
return webglSupported;
}
/**
* Renders an image with chroma key background removal.
*
* Attempts WebGL2 shader processing for GPU acceleration.
* Falls back to Canvas 2D pixel manipulation if WebGL is unavailable.
* The result is cached — re-processes only when parameters change.
*/
export const ChromaKeyImage: React.FC<ChromaKeyImageProps> = ({
src,
chromaKeyColor,
chromaKeyTolerance,
chromaKeySoftness,
style = {},
draggable = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const shaderRef = useRef<ChromaKeyShader | null>(null);
const useWebGL = useRef(isWebGLSupported());
const keyColor = useMemo(() => hexToRgb(chromaKeyColor), [chromaKeyColor]);
const canvas2dParams = useMemo(() => ({
keyColor,
tolerance: mapToleranceToDistance(chromaKeyTolerance),
softness: mapSoftnessToDistance(chromaKeySoftness),
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
const webglParams = useMemo(() => ({
keyColor,
tolerance: chromaKeyTolerance / 100 * 0.8, // Normalize to 0-0.8 range for shader
softness: chromaKeySoftness / 100 * 0.4, // Normalize to 0-0.4 range
spillSuppress: 0.5,
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
// Cleanup shader on unmount
useEffect(() => {
return () => {
shaderRef.current?.dispose();
shaderRef.current = null;
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !src) return;
const processWithImage = (img: HTMLImageElement) => {
if (useWebGL.current) {
try {
// Initialize shader lazily
if (!shaderRef.current) {
shaderRef.current = new ChromaKeyShader(canvas);
}
shaderRef.current.render(img, webglParams);
} catch (e) {
console.warn('ChromaKeyImage: WebGL failed, falling back to Canvas 2D', e);
useWebGL.current = false;
shaderRef.current?.dispose();
shaderRef.current = null;
processCanvas2D(img, canvas, canvas2dParams);
}
} else {
processCanvas2D(img, canvas, canvas2dParams);
}
};
// If image already loaded, process immediately
if (imgRef.current && imgRef.current.src === src && imgRef.current.complete) {
processWithImage(imgRef.current);
return;
}
// Load image
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
imgRef.current = img;
processWithImage(img);
};
img.onerror = () => {
console.warn('ChromaKeyImage: failed to load image', src);
};
img.src = src;
return () => {
img.onload = null;
img.onerror = null;
};
}, [src, canvas2dParams, webglParams]);
return (
<canvas
ref={canvasRef}
style={{
...style,
imageRendering: 'auto',
}}
draggable={draggable}
/>
);
};
function processCanvas2D(
img: HTMLImageElement,
canvas: HTMLCanvasElement,
params: { keyColor: [number, number, number]; tolerance: number; softness: number }
): void {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
applyChromaKey(imageData, params);
ctx.putImageData(imageData, 0, 0);
}
@@ -0,0 +1,191 @@
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import { useCurrentFrame, useVideoConfig } from 'remotion';
import {
applyChromaKey,
hexToRgb,
mapToleranceToDistance,
mapSoftnessToDistance,
} from '../../utils/chromaKeyUtils';
import { ChromaKeyShader } from '../../utils/ChromaKeyShader';
interface ChromaKeyVideoProps {
src: string;
chromaKeyColor: string;
chromaKeyTolerance: number;
chromaKeySoftness: number;
style?: React.CSSProperties;
volume?: number | ((frame: number) => number);
}
// Cache WebGL support check
let webglSupported: boolean | null = null;
function isWebGLSupported(): boolean {
if (webglSupported === null) {
webglSupported = ChromaKeyShader.isSupported();
}
return webglSupported;
}
/**
* Renders a video with chroma key background removal.
*
* Uses a hidden <video> element synced to Remotion's current frame.
* Attempts WebGL2 shader processing for GPU-accelerated performance.
* Falls back to Canvas 2D pixel manipulation if WebGL is unavailable.
*
* Canvas 2D fallback processes at 50% resolution for performance.
*/
export const ChromaKeyVideo: React.FC<ChromaKeyVideoProps> = ({
src,
chromaKeyColor,
chromaKeyTolerance,
chromaKeySoftness,
style = {},
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const shaderRef = useRef<ChromaKeyShader | null>(null);
const rafRef = useRef<number>(0);
const useWebGL = useRef(isWebGLSupported());
const keyColor = useMemo(() => hexToRgb(chromaKeyColor), [chromaKeyColor]);
const canvas2dParams = useMemo(() => ({
keyColor,
tolerance: mapToleranceToDistance(chromaKeyTolerance),
softness: mapSoftnessToDistance(chromaKeySoftness),
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
const webglParams = useMemo(() => ({
keyColor,
tolerance: chromaKeyTolerance / 100 * 0.8,
softness: chromaKeySoftness / 100 * 0.4,
spillSuppress: 0.5,
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
// Cleanup shader on unmount
useEffect(() => {
return () => {
shaderRef.current?.dispose();
shaderRef.current = null;
cancelAnimationFrame(rafRef.current);
};
}, []);
// Process current video frame
const processFrame = useCallback(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas || video.readyState < 2) return;
if (useWebGL.current) {
try {
if (!shaderRef.current) {
shaderRef.current = new ChromaKeyShader(canvas);
}
shaderRef.current.render(video, webglParams);
} catch (e) {
console.warn('ChromaKeyVideo: WebGL failed, falling back to Canvas 2D', e);
useWebGL.current = false;
shaderRef.current?.dispose();
shaderRef.current = null;
processCanvas2D(video, canvas, canvas2dParams);
}
} else {
processCanvas2D(video, canvas, canvas2dParams);
}
}, [canvas2dParams, webglParams]);
// Sync video time to Remotion frame
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const targetTime = frame / fps;
// Only seek if significantly out of sync
if (Math.abs(video.currentTime - targetTime) > 0.05) {
video.currentTime = targetTime;
}
// Process after seek
const onSeeked = () => processFrame();
video.addEventListener('seeked', onSeeked, { once: true });
// Also process immediately if video is ready
if (video.readyState >= 2) {
rafRef.current = requestAnimationFrame(processFrame);
}
return () => {
video.removeEventListener('seeked', onSeeked);
cancelAnimationFrame(rafRef.current);
};
}, [frame, fps, processFrame]);
// Re-process when chroma key params change
useEffect(() => {
if (videoRef.current && videoRef.current.readyState >= 2) {
processFrame();
}
}, [canvas2dParams, webglParams, processFrame]);
return (
<div style={{ position: 'relative', ...style }}>
{/* Hidden video element for frame source */}
<video
ref={videoRef}
src={src}
style={{
position: 'absolute',
width: 0,
height: 0,
opacity: 0,
pointerEvents: 'none',
}}
muted
playsInline
preload="auto"
crossOrigin="anonymous"
/>
{/* Visible canvas with processed frames */}
<canvas
ref={canvasRef}
style={{
width: '100%',
height: '100%',
objectFit: (style as any).objectFit || 'contain',
pointerEvents: 'none',
userSelect: 'none',
}}
/>
</div>
);
};
function processCanvas2D(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
params: { keyColor: [number, number, number]; tolerance: number; softness: number }
): void {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
// Process at 50% resolution for performance
const scale = 0.5;
const w = Math.round(video.videoWidth * scale);
const h = Math.round(video.videoHeight * scale);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
ctx.clearRect(0, 0, w, h);
ctx.drawImage(video, 0, 0, w, h);
const imageData = ctx.getImageData(0, 0, w, h);
applyChromaKey(imageData, params);
ctx.putImageData(imageData, 0, 0);
}
@@ -0,0 +1,628 @@
import React, { RefObject, useEffect } from 'react';
import { Sequence, AbsoluteFill, Img, Video, Audio, interpolate } from 'remotion';
import { TimelineElement, TimelineLayer, DesignMD } from '../../types';
import { calculateElementTransitions } from './useTransitions';
import { resolveKeyframes } from './keyframeEngine';
import { ChromaKeyImage } from './ChromaKeyImage';
import { ChromaKeyVideo } from './ChromaKeyVideo';
import type { CanvasActionMode } from './ElementActionToolbar';
import { loadGoogleFont } from '../../utils/googleFontsApi';
interface CompositionElementProps {
element: TimelineElement;
layer: TimelineLayer | undefined;
designMD: DesignMD;
frame: number;
selectedElementId: string | null;
activeLayerId: string | null;
activeAction: CanvasActionMode;
isImageMode?: boolean;
tempPositions: Record<string, { x: number; y: number; scale?: number; rotation?: number }>;
dragStateId: string | null;
containerRef: RefObject<HTMLDivElement>;
onElementClick?: (id: string) => void;
onElementDoubleClick?: (id: string) => void;
onElementContextMenu?: (id: string, e: React.MouseEvent) => void;
onDragStart: (id: string, startX: number, startY: number, initialElX: number, initialElY: number) => void;
onTransformStart: (id: string, type: 'scale' | 'rotate', startX: number, startY: number, initialScale: number, initialRot: number, centerX: number, centerY: number) => void;
onElementDuplicate?: (id: string) => void;
onElementDelete?: (id: string) => void;
onElementLock?: (id: string) => void;
}
export const CompositionElement: React.FC<CompositionElementProps> = ({
element: el,
layer,
designMD,
frame,
selectedElementId,
activeLayerId,
activeAction,
isImageMode = false,
tempPositions,
dragStateId,
containerRef,
onElementClick,
onElementDoubleClick,
onElementContextMenu,
onDragStart,
onTransformStart,
onElementDuplicate,
onElementDelete,
onElementLock,
}) => {
// ─── Dynamic font loading for text elements ───
const fontFamily = el.type === 'text' ? (el.fontFamily ?? designMD.baseFont) : null;
useEffect(() => {
if (fontFamily) loadGoogleFont(fontFamily);
}, [fontFamily]);
// In image mode: all non-locked elements are interactive (Photoshop model)
// In video mode: only elements on the active layer are interactive
const isInteractive = isImageMode
? !el.isLocked
: (!!activeLayerId && el.layerId === activeLayerId) && !el.isLocked;
// Skip hidden elements (after all hooks to satisfy Rules of Hooks)
if (el.isHidden) return null;
const isSelected = selectedElementId === el.id;
const layerOpacity = layer?.opacity ?? 1;
const baseOpacity = ((el.opacity ?? 100) / 100) * layerOpacity;
const currentScale = tempPositions[el.id]?.scale ?? el.scale ?? 1;
const currentRot = tempPositions[el.id]?.rotation ?? el.rotation ?? 0;
const tempX = tempPositions[el.id]?.x;
const tempY = tempPositions[el.id]?.y;
const { opacity, transformStr, displayContent } = calculateElementTransitions(
el, frame, baseOpacity, currentScale, currentRot, tempX, tempY
);
// Resolve position — multi-keyframes take priority over legacy animEnd*
let currentX = tempX ?? el.x;
let currentY = tempY ?? el.y;
if (el.keyframes && el.keyframes.length >= 2 && !tempPositions[el.id]) {
// Multi-keyframe: resolve x/y from keyframe engine
const resolved = resolveKeyframes(el.keyframes, frame, {
x: el.x, y: el.y,
scale: currentScale, opacity: baseOpacity, rotation: currentRot,
});
currentX = resolved.x;
currentY = resolved.y;
} else if (!el.keyframes) {
// Legacy 2-point keyframes
if (el.animEndX !== undefined) {
currentX = interpolate(frame, [el.startFrame, el.endFrame], [tempX ?? el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
if (el.animEndY !== undefined) {
currentY = interpolate(frame, [el.startFrame, el.endFrame], [tempY ?? el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
}
const isFullscreenBrand = el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video';
const resolvedBlendMode = (() => {
// When chroma key is active, transparency is handled by the canvas — no CSS blend needed
if (el.chromaKeyEnabled) return 'normal';
if (!el.isBrandElement) return el.blendMode || 'normal';
if (el.content === designMD.introVideoUrl) return designMD.introBlendMode || el.blendMode || 'normal';
if (el.content === designMD.outroVideoUrl) return designMD.outroBlendMode || el.blendMode || 'normal';
return el.blendMode || 'normal';
})();
// Chroma key defaults
const ckColor = el.chromaKeyColor || '#ffffff';
const ckTolerance = el.chromaKeyTolerance ?? 30;
const ckSoftness = el.chromaKeySoftness ?? 10;
const filterStr = `brightness(${el.brightness ?? 100}%) contrast(${el.contrast ?? 100}%) saturate(${el.saturation ?? 100}%)${el.hueRotate ? ` hue-rotate(${el.hueRotate}deg)` : ''}${el.sepia ? ` sepia(${el.sepia}%)` : ''}${el.blurAmount ? ` blur(${el.blurAmount}px)` : ''}`;
// Contain background: wrap media in a colored container when objectFit='contain' and color is set
const hasContainBg = (el.objectFit === 'contain' || !el.objectFit) && !!el.containBgColor;
const containBgStyle: React.CSSProperties | undefined = hasContainBg ? {
width: '100%',
height: el.height ? '100%' : 'auto',
backgroundColor: el.containBgColor!,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
} : undefined;
// ── Transform helpers ──
const startScaleDrag = (e: React.PointerEvent) => {
e.stopPropagation();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
onTransformStart(
el.id, 'scale', e.clientX, e.clientY,
currentScale, currentRot,
rect.left + (currentX / 100) * rect.width,
rect.top + (currentY / 100) * rect.height
);
};
const startRotateDrag = (e: React.PointerEvent) => {
e.stopPropagation();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
onTransformStart(
el.id, 'rotate', e.clientX, e.clientY,
currentScale, currentRot,
rect.left + (currentX / 100) * rect.width,
rect.top + (currentY / 100) * rect.height
);
};
const startDrag = (e: React.PointerEvent) => {
if (!isInteractive) return;
e.stopPropagation();
if (e.button === 2) return;
if (onElementClick) onElementClick(el.id);
// In move mode: drag moves. In scale/rotate: start respective transform.
if (activeAction === 'move') {
onDragStart(el.id, e.clientX, e.clientY, currentX, currentY);
} else if (activeAction === 'scale') {
startScaleDrag(e);
} else if (activeAction === 'rotate') {
startRotateDrag(e);
}
};
// ── Selection outline color ──
const outlineColor = el.isLocked ? '#d97706' : '#8b5cf6';
return (
<Sequence from={el.startFrame} durationInFrames={Math.max(1, el.endFrame - el.startFrame)}>
{el.type === 'audio' ? ((() => {
const layerVol = (layer?.volume ?? 100) / 100;
const elVol = el.volume ?? 1;
const isMuted = layer?.isMuted === true;
// Build volume callback for Remotion <Audio>
const volumeCallback = (f: number) => {
if (isMuted) return 0;
let vol = layerVol * elVol;
// Fade in
const fadeIn = el.fadeInFrames ?? 0;
if (fadeIn > 0 && f < fadeIn) {
vol *= interpolate(f, [0, fadeIn], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
}
// Fade out
const fadeOut = el.fadeOutFrames ?? 0;
const clipDuration = el.endFrame - el.startFrame;
if (fadeOut > 0 && f > clipDuration - fadeOut) {
vol *= interpolate(f, [clipDuration - fadeOut, clipDuration], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
}
// Volume keyframes
const vkfs = el.volumeKeyframes;
if (vkfs && vkfs.length > 0) {
const sorted = [...vkfs].sort((a, b) => a.frame - b.frame);
let before = sorted[0];
let after = sorted[sorted.length - 1];
for (let i = 0; i < sorted.length - 1; i++) {
if (f >= sorted[i].frame && f <= sorted[i + 1].frame) {
before = sorted[i];
after = sorted[i + 1];
break;
}
}
if (f <= before.frame) {
vol *= before.volume;
} else if (f >= after.frame) {
vol *= after.volume;
} else {
const kfVol = interpolate(f, [before.frame, after.frame], [before.volume, after.volume], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
vol *= kfVol;
}
}
return Math.max(0, Math.min(1, vol));
};
return <Audio src={el.content} volume={volumeCallback} />;
})()) : isFullscreenBrand ? (
/* ═══ Fullscreen Brand Video ═══ */
<AbsoluteFill
style={{
cursor: isInteractive ? 'pointer' : 'default',
pointerEvents: isInteractive ? 'auto' : 'none',
mixBlendMode: resolvedBlendMode !== 'normal' ? resolvedBlendMode as React.CSSProperties['mixBlendMode'] : undefined,
}}
onClick={(e) => {
e.stopPropagation();
if (isInteractive && onElementClick) onElementClick(el.id);
}}
>
{/* Positioned container — matches branding preview (PreviewRemotion) */}
<div style={{
position: 'absolute',
left: `${el.x ?? 0}%`,
top: `${el.y ?? 0}%`,
width: `${el.w ?? 100}%`,
height: `${el.h ?? 100}%`,
overflow: 'hidden',
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
}}>
{el.chromaKeyEnabled ? (
<ChromaKeyVideo
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
style={{
width: '100%',
height: '100%',
objectFit: (el.objectFit || (() => {
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
return 'cover';
})()) as React.CSSProperties['objectFit'],
opacity: opacity,
filter: filterStr,
}}
/>
) : (
<Video
src={el.content}
volume={el.volume ?? 1}
style={{
width: '100%',
height: '100%',
objectFit: (el.objectFit || (() => {
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
return 'cover';
})()) as React.CSSProperties['objectFit'],
opacity: opacity,
pointerEvents: 'none',
filter: filterStr,
}}
/>
)}
</div>
{isSelected && (
<div style={{
position: 'absolute',
left: `${el.x ?? 0}%`,
top: `${el.y ?? 0}%`,
width: `${el.w ?? 100}%`,
height: `${el.h ?? 100}%`,
border: '3px solid #d97706',
pointerEvents: 'none',
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
}} />
)}
</AbsoluteFill>
) : (
/* ═══ Normal Positioned Element ═══ */
<AbsoluteFill style={{ pointerEvents: 'none' }}>
<div
style={{
position: 'absolute',
left: `${currentX}%`,
top: `${currentY}%`,
width: el.type === 'text' ? (el.width ? `${el.width}%` : undefined) : `${el.width ?? 25}%`,
height: el.height ? `${el.height}%` : undefined,
transform: `${transformStr}${el.flipH ? ' scaleX(-1)' : ''}${el.flipV ? ' scaleY(-1)' : ''}`,
opacity: opacity,
cursor: isInteractive
? (activeAction === 'move'
? (dragStateId === el.id ? 'grabbing' : 'grab')
: activeAction === 'scale' ? 'nwse-resize'
: activeAction === 'rotate' ? 'alias'
: 'grab')
: (el.isLocked ? 'not-allowed' : 'default'),
outline: isSelected ? `${Math.max(1, 3 / currentScale)}px dashed ${outlineColor}` : 'none',
outlineOffset: `${6 / currentScale}px`,
pointerEvents: isInteractive || isSelected ? 'auto' : 'none',
mixBlendMode: resolvedBlendMode !== 'normal' ? resolvedBlendMode as React.CSSProperties['mixBlendMode'] : undefined,
border: el.borderWidth ? `${el.borderWidth}px ${el.borderStyle ?? 'solid'} ${el.borderColor ?? '#ffffff'}` : undefined,
borderRadius: el.borderRadius ? `${el.borderRadius}px` : undefined,
overflow: (el.height || el.borderRadius) ? 'hidden' : undefined,
boxShadow: el.boxShadowBlur || el.boxShadowX || el.boxShadowY
? `${el.boxShadowX ?? 0}px ${el.boxShadowY ?? 4}px ${el.boxShadowBlur ?? 10}px ${el.boxShadowColor ?? 'rgba(0,0,0,0.5)'}`
: undefined,
}}
onClick={(e) => { e.stopPropagation(); }}
onDoubleClick={(e) => {
e.stopPropagation();
if (isInteractive && onElementDoubleClick) onElementDoubleClick(el.id);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (isInteractive && onElementContextMenu) onElementContextMenu(el.id, e as unknown as React.MouseEvent);
}}
onPointerDown={startDrag}
>
{/* ── Content ── */}
{el.type === 'text' ? (
<div
style={{
fontFamily: el.fontFamily ?? designMD.baseFont,
color: el.color ?? designMD.textColor,
fontSize: el.fontSize ? `${el.fontSize}px` : '56px',
fontWeight: el.fontWeight ?? 'bold',
fontStyle: el.fontStyle ?? 'normal',
textDecoration: el.textDecoration && el.textDecoration !== 'none' ? el.textDecoration : undefined,
textShadow: `${el.shadowOffset ?? 3}px ${el.shadowOffset ?? 3}px ${el.shadowBlur ?? 6}px ${el.shadowColor ?? 'rgba(0,0,0,0.8)'}`,
textAlign: el.textAlign ?? 'center',
lineHeight: el.lineHeight ?? 1.2,
letterSpacing: el.letterSpacing ? `${el.letterSpacing}px` : undefined,
textTransform: el.textTransform ?? 'none',
WebkitTextStroke: el.textStrokeWidth
? `${el.textStrokeWidth}px ${el.textStrokeColor ?? '#000000'}`
: undefined,
// Gradient text (overrides solid color)
...(el.textGradient ? {
background: el.textGradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
} : el.textBackground ? {
// Text background (pill/highlight)
background: el.textBackground,
padding: `${el.textBackgroundPadding ?? 8}px ${(el.textBackgroundPadding ?? 8) * 2}px`,
borderRadius: `${el.textBackgroundRadius ?? 4}px`,
display: 'inline-block',
} : {}),
whiteSpace: 'pre-wrap',
userSelect: 'none',
pointerEvents: 'none',
}}
>
{displayContent}
</div>
) : el.type === 'video' ? (
(() => {
const videoContent = el.chromaKeyEnabled ? (
<ChromaKeyVideo
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
playbackRate={el.playbackRate}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
) : (
<Video
src={el.content}
volume={el.volume ?? 1}
playbackRate={el.playbackRate ?? 1}
startFrom={el.trimStartSec ? Math.round(el.trimStartSec * 30) : undefined}
endAt={el.trimEndSec ? Math.round(el.trimEndSec * 30) : undefined}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
);
return hasContainBg ? <div style={containBgStyle}>{videoContent}</div> : videoContent;
})()
) : el.type === 'shape' ? (
/* ── Shape Element (SVG) ── */
(() => {
const sw = el.width ?? 25;
const fill = el.shapeFill ?? '#ffffff';
const stroke = el.shapeStroke ?? 'none';
const strokeW = el.shapeStrokeWidth ?? 0;
const cr = el.shapeCornerRadius ?? 0;
const svgStyle: React.CSSProperties = {
width: '100%',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
};
switch (el.shapeType) {
case 'circle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<circle cx="50" cy="50" r={48 - strokeW / 2} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
case 'triangle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,2 98,98 2,98" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'line':
return (
<svg viewBox="0 0 100 10" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="5" x2="100" y2="5" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
</svg>
);
case 'arrow':
return (
<svg viewBox="0 0 100 40" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="20" x2="80" y2="20" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
<polygon points="75,5 100,20 75,35" fill={stroke || fill} />
</svg>
);
case 'star':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,0 61,35 98,35 68,57 79,91 50,70 21,91 32,57 2,35 39,35" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'rectangle':
default:
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<rect x={strokeW / 2} y={strokeW / 2} width={100 - strokeW} height={100 - strokeW} rx={cr} ry={cr} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
}
})()
) : el.isPlaceholder ? (
/* ── Placeholder for empty media fields ── */
<div
style={{
width: '100%',
height: el.height ? `${el.height}%` : '100%',
aspectRatio: el.height ? undefined : '16/9',
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
background: 'rgba(255,255,255,0.03)',
pointerEvents: 'none',
userSelect: 'none',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
{el.placeholderLabel && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
{el.placeholderLabel}
</span>
)}
</div>
) : (
(() => {
const imgContent = el.chromaKeyEnabled ? (
<ChromaKeyImage
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
) : (
<Img
src={el.content}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
);
return hasContainBg ? <div style={containBgStyle}>{imgContent}</div> : imgContent;
})()
)}
{/* ═══ Scale Handles — only in Scale mode ═══ */}
{isSelected && activeAction === 'scale' && (
<>
{(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((corner) => {
const isTop = corner.includes('top');
const isLeft = corner.includes('left');
const cursorH = (isTop === isLeft) ? 'nwse-resize' : 'nesw-resize';
return (
<div
key={corner}
style={{
position: 'absolute',
[isTop ? 'top' : 'bottom']: -7 / currentScale,
[isLeft ? 'left' : 'right']: -7 / currentScale,
width: 14 / currentScale,
height: 14 / currentScale,
background: '#fff',
border: `${Math.max(1, 2 / currentScale)}px solid #8b5cf6`,
borderRadius: 3 / currentScale,
cursor: cursorH,
pointerEvents: 'auto',
zIndex: 10,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
onPointerDown={startScaleDrag}
title="Redimensionar"
/>
);
})}
</>
)}
{/* ═══ Rotate Handle — only in Rotate mode ═══ */}
{isSelected && activeAction === 'rotate' && (
<>
{/* Connector line from element bottom to rotate handle */}
<div
style={{
position: 'absolute', bottom: -24 / currentScale, left: '50%',
transform: `translateX(-50%) scaleY(${1 / currentScale})`,
transformOrigin: 'top center',
width: 1, height: 20,
background: '#8b5cf6',
pointerEvents: 'none',
zIndex: 9,
}}
/>
{/* Rotate handle circle */}
<div
style={{
position: 'absolute', bottom: -38 / currentScale, left: '50%',
transform: `translateX(-50%) scale(${1 / currentScale})`,
transformOrigin: 'top center',
width: 22, height: 22,
background: '#fff', border: '2px solid #8b5cf6',
borderRadius: '50%', cursor: 'grab',
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 51,
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
}}
onPointerDown={startRotateDrag}
title="Arrastra para rotar"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.59-9.21l5.67-4.24"/></svg>
</div>
</>
)}
</div>
</AbsoluteFill>
)}
</Sequence>
);
};
@@ -0,0 +1,241 @@
import React from 'react';
export type CanvasActionMode = 'move' | 'scale' | 'rotate';
interface ElementActionToolbarProps {
activeAction: CanvasActionMode;
setActiveAction: (action: CanvasActionMode) => void;
isLocked: boolean;
isBrandElement: boolean;
onDuplicate: () => void;
onDelete: () => void;
onLock: () => void;
counterScale?: number;
// Keyframe props
hasKeyframes?: boolean;
hasKeyframeAtCurrentFrame?: boolean;
onToggleKeyframe?: () => void;
onPrevKeyframe?: () => void;
onNextKeyframe?: () => void;
}
/**
* Floating toolbar rendered above a selected canvas element.
* Controls the active interaction mode (move/scale/rotate) and provides
* quick actions (duplicate, lock, delete, keyframe toggle).
*/
export const ElementActionToolbar: React.FC<ElementActionToolbarProps> = ({
activeAction,
setActiveAction,
isLocked,
isBrandElement,
onDuplicate,
onDelete,
onLock,
counterScale = 1,
hasKeyframes = false,
hasKeyframeAtCurrentFrame = false,
onToggleKeyframe,
onPrevKeyframe,
onNextKeyframe,
}) => {
return (
<div
style={{
zIndex: 50,
pointerEvents: 'auto',
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 2,
background: 'rgba(23, 23, 23, 0.95)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(82, 82, 82, 0.4)',
borderRadius: 8,
padding: '3px 4px',
boxShadow: '0 4px 20px rgba(0,0,0,0.5), 0 0 0 1px rgba(139,92,246,0.15)',
}}
>
{/* ── Mode buttons ── */}
<ToolbarBtn
active={activeAction === 'move'}
onClick={() => setActiveAction('move')}
title="Mover (M)"
disabled={isLocked}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="5 9 2 12 5 15" /><polyline points="9 5 12 2 15 5" />
<polyline points="15 19 12 22 9 19" /><polyline points="19 9 22 12 19 15" />
<line x1="2" y1="12" x2="22" y2="12" /><line x1="12" y1="2" x2="12" y2="22" />
</svg>
</ToolbarBtn>
<ToolbarBtn
active={activeAction === 'scale'}
onClick={() => setActiveAction('scale')}
title="Redimensionar (S)"
disabled={isLocked}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" />
</svg>
</ToolbarBtn>
<ToolbarBtn
active={activeAction === 'rotate'}
onClick={() => setActiveAction('rotate')}
title="Rotar (R)"
disabled={isLocked}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.5 2v6h-6" /><path d="M21.34 15.57a10 10 0 1 1-.59-9.21l5.67-4.24" />
</svg>
</ToolbarBtn>
{/* Separator */}
<div style={{ width: 1, height: 18, background: 'rgba(82,82,82,0.5)', margin: '0 2px' }} />
{/* ── Keyframe toggle (CapCut style) ── */}
{hasKeyframes && onPrevKeyframe && (
<ToolbarBtn onClick={onPrevKeyframe} title="Keyframe anterior (←)" disabled={isLocked}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</ToolbarBtn>
)}
{onToggleKeyframe && (
<ToolbarBtn
onClick={onToggleKeyframe}
title={hasKeyframeAtCurrentFrame ? 'Eliminar keyframe' : 'Agregar keyframe'}
active={hasKeyframeAtCurrentFrame}
disabled={isLocked}
>
{/* Diamond icon ◆ */}
<svg width="14" height="14" viewBox="0 0 24 24">
<path
d="M12 2 L22 12 L12 22 L2 12 Z"
fill={hasKeyframeAtCurrentFrame ? '#a78bfa' : 'none'}
stroke={hasKeyframeAtCurrentFrame ? '#a78bfa' : 'currentColor'}
strokeWidth="2"
/>
</svg>
</ToolbarBtn>
)}
{hasKeyframes && onNextKeyframe && (
<ToolbarBtn onClick={onNextKeyframe} title="Siguiente keyframe (→)" disabled={isLocked}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</ToolbarBtn>
)}
{(onToggleKeyframe || hasKeyframes) && (
<div style={{ width: 1, height: 18, background: 'rgba(82,82,82,0.5)', margin: '0 2px' }} />
)}
{/* ── Quick actions ── */}
<ToolbarBtn onClick={onDuplicate} title="Duplicar (D)" disabled={isLocked}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</ToolbarBtn>
<ToolbarBtn
onClick={onLock}
title={isLocked ? 'Desbloquear' : 'Bloquear'}
active={isLocked}
>
{isLocked ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 9.9-1" />
</svg>
)}
</ToolbarBtn>
{!isBrandElement && (
<ToolbarBtn onClick={onDelete} title="Eliminar (⌫)" danger>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</ToolbarBtn>
)}
</div>
</div>
);
};
// ─── Button sub-component ──────────────────────────────
interface ToolbarBtnProps {
children: React.ReactNode;
onClick?: () => void;
title: string;
active?: boolean;
disabled?: boolean;
danger?: boolean;
}
const ToolbarBtn: React.FC<ToolbarBtnProps> = ({ children, onClick, title, active, disabled, danger }) => {
const bg = active
? 'rgba(139, 92, 246, 0.3)'
: 'transparent';
const color = danger
? 'rgb(248, 113, 113)'
: active
? 'rgb(196, 167, 255)'
: 'rgb(163, 163, 163)';
const hoverBg = danger
? 'rgba(248, 113, 113, 0.15)'
: 'rgba(255, 255, 255, 0.08)';
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
onPointerDown={(e) => e.stopPropagation()}
title={title}
disabled={disabled}
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
border: active ? '1px solid rgba(139, 92, 246, 0.5)' : '1px solid transparent',
background: bg,
color: color,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.3 : 1,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!disabled && !active) {
e.currentTarget.style.background = hoverBg;
e.currentTarget.style.color = danger ? 'rgb(248, 113, 113)' : 'white';
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.background = bg;
e.currentTarget.style.color = color;
}
}}
>
{children}
</button>
);
};
@@ -0,0 +1,93 @@
import React from 'react';
interface SafeZoneOverlayProps {
visible: boolean;
}
/**
* Displays social media safe zone guides on the canvas.
* Shows title-safe (80%) and action-safe (90%) zones.
*/
export const SafeZoneOverlay: React.FC<SafeZoneOverlayProps> = ({ visible }) => {
if (!visible) return null;
return (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 45,
}}
>
{/* Title Safe (80%) */}
<div
style={{
position: 'absolute',
left: '10%',
top: '10%',
right: '10%',
bottom: '10%',
border: '1px dashed rgba(236, 72, 153, 0.4)',
borderRadius: 4,
}}
>
<span
style={{
position: 'absolute',
top: -14,
left: 4,
fontSize: 8,
color: 'rgba(236, 72, 153, 0.6)',
fontFamily: 'monospace',
userSelect: 'none',
}}
>
Title Safe 80%
</span>
</div>
{/* Action Safe (90%) */}
<div
style={{
position: 'absolute',
left: '5%',
top: '5%',
right: '5%',
bottom: '5%',
border: '1px dashed rgba(168, 85, 247, 0.3)',
borderRadius: 4,
}}
>
<span
style={{
position: 'absolute',
top: -14,
left: 4,
fontSize: 8,
color: 'rgba(168, 85, 247, 0.5)',
fontFamily: 'monospace',
userSelect: 'none',
}}
>
Action Safe 90%
</span>
</div>
{/* Center cross */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: 20,
height: 20,
transform: 'translate(-50%, -50%)',
}}
>
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1, backgroundColor: 'rgba(168, 85, 247, 0.2)' }} />
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 1, backgroundColor: 'rgba(168, 85, 247, 0.2)' }} />
</div>
</div>
);
};
@@ -0,0 +1,18 @@
import React from 'react';
interface SmartGuidesProps {
guides: { x: number | null; y: number | null };
}
export const SmartGuides: React.FC<SmartGuidesProps> = ({ guides }) => {
return (
<>
{guides.x !== null && (
<div style={{ position: 'absolute', left: `${guides.x}%`, top: 0, bottom: 0, width: '2px', backgroundColor: '#ec4899', zIndex: 50, pointerEvents: 'none' }} />
)}
{guides.y !== null && (
<div style={{ position: 'absolute', top: `${guides.y}%`, left: 0, right: 0, height: '2px', backgroundColor: '#ec4899', zIndex: 50, pointerEvents: 'none' }} />
)}
</>
);
};
@@ -0,0 +1,158 @@
import { interpolate, Easing } from 'remotion';
import { AnimationKeyframe, EasingType } from '../../types';
interface KeyframeDefaults {
x: number;
y: number;
scale: number;
opacity: number;
rotation: number;
}
interface ResolvedValues {
x: number;
y: number;
scale: number;
opacity: number;
rotation: number;
}
/**
* Map EasingType to a Remotion Easing function.
*/
function getEasingFn(type: EasingType = 'linear'): (t: number) => number {
switch (type) {
case 'ease-in': return Easing.in(Easing.ease);
case 'ease-out': return Easing.out(Easing.ease);
case 'ease-in-out': return Easing.inOut(Easing.ease);
case 'bounce': return Easing.bounce;
case 'spring': return Easing.out(Easing.ease); // Approximation — spring is better via spring()
case 'linear':
default: return Easing.linear;
}
}
/**
* Build a filled property array from keyframes.
* If a keyframe doesn't define a property, it inherits the previous keyframe's value.
*/
function buildPropertyTrack(
sortedKfs: AnimationKeyframe[],
property: keyof Omit<AnimationKeyframe, 'frame' | 'easing'>,
defaultValue: number
): number[] {
let lastValue = defaultValue;
return sortedKfs.map(kf => {
const v = kf[property];
if (v !== undefined) {
lastValue = v;
return v;
}
return lastValue;
});
}
/**
* Resolve multi-keyframe interpolation for a given frame.
*
* @param keyframes - Array of keyframes (will be sorted by frame)
* @param frame - Current absolute frame number
* @param defaults - Default values for all properties (used before the first keyframe)
* @returns Interpolated property values at the given frame
*/
export function resolveKeyframes(
keyframes: AnimationKeyframe[],
frame: number,
defaults: KeyframeDefaults
): ResolvedValues {
if (keyframes.length === 0) return { ...defaults };
// Sort by frame
const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
// If only one keyframe, return its values (no interpolation)
if (sorted.length === 1) {
const kf = sorted[0];
return {
x: kf.x ?? defaults.x,
y: kf.y ?? defaults.y,
scale: kf.scale ?? defaults.scale,
opacity: kf.opacity ?? defaults.opacity,
rotation: kf.rotation ?? defaults.rotation,
};
}
// Build frame array (inputRange)
const frames = sorted.map(kf => kf.frame);
// Build easing array (one per segment = keyframes.length - 1)
const easings = sorted.slice(1).map(kf => getEasingFn(kf.easing));
// Build and interpolate each property
const interpolateProperty = (
property: keyof Omit<AnimationKeyframe, 'frame' | 'easing'>,
defaultValue: number
): number => {
const values = buildPropertyTrack(sorted, property, defaultValue);
return interpolate(frame, frames, values, {
easing: easings as ((t: number) => number)[],
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
};
return {
x: interpolateProperty('x', defaults.x),
y: interpolateProperty('y', defaults.y),
scale: interpolateProperty('scale', defaults.scale),
opacity: interpolateProperty('opacity', defaults.opacity),
rotation: interpolateProperty('rotation', defaults.rotation),
};
}
/**
* Find the keyframe at a specific frame (within ±tolerance).
*/
export function findKeyframeAtFrame(
keyframes: AnimationKeyframe[],
frame: number,
tolerance: number = 2
): { index: number; keyframe: AnimationKeyframe } | null {
for (let i = 0; i < keyframes.length; i++) {
if (Math.abs(keyframes[i].frame - frame) <= tolerance) {
return { index: i, keyframe: keyframes[i] };
}
}
return null;
}
/**
* Add or update a keyframe at a specific frame.
* If a keyframe exists within ±tolerance, update it. Otherwise, insert a new one.
*/
export function upsertKeyframe(
keyframes: AnimationKeyframe[],
frame: number,
values: Partial<Omit<AnimationKeyframe, 'frame'>>,
tolerance: number = 2
): AnimationKeyframe[] {
const existing = findKeyframeAtFrame(keyframes, frame, tolerance);
if (existing) {
// Update existing keyframe
return keyframes.map((kf, i) =>
i === existing.index ? { ...kf, ...values } : kf
);
}
// Insert new keyframe
return [...keyframes, { frame, ...values }].sort((a, b) => a.frame - b.frame);
}
/**
* Remove a keyframe at a specific index.
*/
export function removeKeyframe(
keyframes: AnimationKeyframe[],
index: number
): AnimationKeyframe[] {
return keyframes.filter((_, i) => i !== index);
}
+235
View File
@@ -0,0 +1,235 @@
import React, { useState, useEffect, useRef, RefObject } from 'react';
import { TimelineElement } from '../../types';
interface CanvasDragState {
id: string;
startX: number;
startY: number;
initialElX: number;
initialElY: number;
}
interface TransformDragState {
id: string;
type: 'scale' | 'rotate';
startX: number;
startY: number;
initialScale: number;
initialRot: number;
centerX: number;
centerY: number;
}
interface TempPosition {
x: number;
y: number;
scale?: number;
rotation?: number;
}
interface Guides {
x: number | null;
y: number | null;
}
interface UseCanvasDragReturn {
containerRef: RefObject<HTMLDivElement>;
dragState: CanvasDragState | null;
setDragState: React.Dispatch<React.SetStateAction<CanvasDragState | null>>;
transformDragState: TransformDragState | null;
setTransformDragState: React.Dispatch<React.SetStateAction<TransformDragState | null>>;
tempPositions: Record<string, TempPosition>;
guides: Guides;
}
export function useCanvasDrag(
timelineElements: TimelineElement[],
onElementPositionChange?: (id: string, x: number, y: number) => void,
onElementTransformChange?: (id: string, updates: Partial<TimelineElement>) => void
): UseCanvasDragReturn {
const containerRef = useRef<HTMLDivElement>(null);
const [dragState, setDragState] = useState<CanvasDragState | null>(null);
const [transformDragState, setTransformDragState] = useState<TransformDragState | null>(null);
const [guides, setGuides] = useState<Guides>({ x: null, y: null });
const [tempPositions, setTempPositions] = useState<Record<string, TempPosition>>({});
// Stable refs to avoid effect re-runs
const tempPositionsRef = useRef(tempPositions);
tempPositionsRef.current = tempPositions;
const elementsRef = useRef(timelineElements);
elementsRef.current = timelineElements;
const onPosChangeRef = useRef(onElementPositionChange);
onPosChangeRef.current = onElementPositionChange;
const onTransformChangeRef = useRef(onElementTransformChange);
onTransformChangeRef.current = onElementTransformChange;
useEffect(() => {
if (!dragState && !transformDragState) return;
let rafId: number | null = null;
const handlePointerMove = (e: PointerEvent) => {
if (rafId) return; // Throttle to 60fps via rAF
rafId = requestAnimationFrame(() => {
rafId = null;
handleDragUpdate(e);
});
};
const handleDragUpdate = (e: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (transformDragState) {
if (transformDragState.type === 'scale') {
// Distance-from-center approach: works correctly from ANY corner.
// Compare distance from element center at start vs now.
const startDist = Math.sqrt(
Math.pow(transformDragState.startX - transformDragState.centerX, 2) +
Math.pow(transformDragState.startY - transformDragState.centerY, 2)
);
const currentDist = Math.sqrt(
Math.pow(e.clientX - transformDragState.centerX, 2) +
Math.pow(e.clientY - transformDragState.centerY, 2)
);
// Ratio: if pointer moves farther from center → bigger, closer → smaller
const distRatio = startDist > 0 ? currentDist / startDist : 1;
const ratio = Math.max(0.05, transformDragState.initialScale * distRatio);
const el = elementsRef.current.find(e => e.id === transformDragState.id);
setTempPositions(prev => ({
...prev,
[transformDragState.id]: {
x: prev[transformDragState.id]?.x ?? el?.x ?? 50,
y: prev[transformDragState.id]?.y ?? el?.y ?? 50,
scale: ratio,
rotation: prev[transformDragState.id]?.rotation,
}
}));
} else if (transformDragState.type === 'rotate') {
const currentAngle = Math.atan2(e.clientY - transformDragState.centerY, e.clientX - transformDragState.centerX);
const initialAngle = Math.atan2(transformDragState.startY - transformDragState.centerY, transformDragState.startX - transformDragState.centerX);
const diff = (currentAngle - initialAngle) * (180 / Math.PI);
const newRot = transformDragState.initialRot + diff;
const el = elementsRef.current.find(e => e.id === transformDragState.id);
setTempPositions(prev => ({
...prev,
[transformDragState.id]: {
x: prev[transformDragState.id]?.x ?? el?.x ?? 50,
y: prev[transformDragState.id]?.y ?? el?.y ?? 50,
scale: prev[transformDragState.id]?.scale,
rotation: newRot,
}
}));
}
return;
}
if (dragState) {
// Convert pixel delta to percentage of container
const dxPct = (rect.width > 0) ? ((e.clientX - dragState.startX) / rect.width) * 100 : 0;
const dyPct = (rect.height > 0) ? ((e.clientY - dragState.startY) / rect.height) * 100 : 0;
let newX = dragState.initialElX + dxPct;
let newY = dragState.initialElY + dyPct;
// Allow elements to go slightly out of bounds for edge positioning
newX = Math.max(-20, Math.min(120, newX));
newY = Math.max(-20, Math.min(120, newY));
// Snapping logic (Smart Guides)
let snapX: number | null = null;
let snapY: number | null = null;
const snapThreshold = 1.5;
// Snap to center
if (Math.abs(newX - 50) < snapThreshold) { newX = 50; snapX = 50; }
if (Math.abs(newY - 50) < snapThreshold) { newY = 50; snapY = 50; }
// Snap to edges
if (Math.abs(newX) < snapThreshold) { newX = 0; snapX = 0; }
if (Math.abs(newX - 100) < snapThreshold) { newX = 100; snapX = 100; }
if (Math.abs(newY) < snapThreshold) { newY = 0; snapY = 0; }
if (Math.abs(newY - 100) < snapThreshold) { newY = 100; snapY = 100; }
// Snap to quarter grid (25%, 75%)
for (const q of [25, 75]) {
if (Math.abs(newX - q) < snapThreshold) { newX = q; snapX = q; }
if (Math.abs(newY - q) < snapThreshold) { newY = q; snapY = q; }
}
// Snap to other elements (center and edges)
elementsRef.current.forEach(el => {
if (el.id !== dragState.id) {
// Center snap
if (Math.abs(newX - el.x) < snapThreshold) { newX = el.x; snapX = el.x; }
if (Math.abs(newY - el.y) < snapThreshold) { newY = el.y; snapY = el.y; }
}
});
setGuides({ x: snapX, y: snapY });
setTempPositions(prev => ({
...prev,
[dragState.id]: {
...prev[dragState.id],
x: newX,
y: newY
}
}));
}
};
const handlePointerUp = () => {
const temps = tempPositionsRef.current;
if (transformDragState && onTransformChangeRef.current) {
const temp = temps[transformDragState.id];
if (temp) {
const updates: Partial<TimelineElement> = {};
if (temp.scale !== undefined) updates.scale = temp.scale;
if (temp.rotation !== undefined) updates.rotation = temp.rotation;
onTransformChangeRef.current(transformDragState.id, updates);
}
} else if (dragState && onPosChangeRef.current && temps[dragState.id]) {
onPosChangeRef.current(
dragState.id,
temps[dragState.id].x,
temps[dragState.id].y
);
}
const currentDragId = dragState?.id;
const currentTransformId = transformDragState?.id;
setDragState(null);
setTransformDragState(null);
setGuides({ x: null, y: null });
// Clean up temp positions after a short delay to avoid flicker
setTimeout(() => setTempPositions(prev => {
const next = { ...prev };
if (currentDragId) delete next[currentDragId];
if (currentTransformId) delete next[currentTransformId];
return next;
}), 30);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
return () => {
if (rafId) cancelAnimationFrame(rafId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [dragState, transformDragState]);
return {
containerRef,
dragState,
setDragState,
transformDragState,
setTransformDragState,
tempPositions,
guides,
};
}
@@ -0,0 +1,216 @@
import { interpolate, spring } from 'remotion';
import { TimelineElement } from '../../types';
import { resolveKeyframes } from './keyframeEngine';
interface TransitionResult {
opacity: number;
transformStr: string;
displayContent: string;
}
/**
* Calculate full transition state (in, out, typewriter, keyframe animations)
* for a single timeline element at a given frame.
*/
export function calculateElementTransitions(
el: TimelineElement,
frame: number,
baseOpacity: number,
currentScale: number,
currentRot: number,
tempX?: number,
tempY?: number
): TransitionResult {
let opacity = baseOpacity;
let transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
let displayContent = el.content;
// --- IN TRANSITIONS ---
if (el.transitionIn) {
const { type, duration } = el.transitionIn;
if (type === 'fade') {
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'slideUp') {
const translateY = interpolate(frame, [el.startFrame, el.startFrame + duration], [50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideRight') {
const translateX = interpolate(frame, [el.startFrame, el.startFrame + duration], [-50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'bounce') {
const scaleAnim = spring({
frame: frame - el.startFrame,
fps: 30,
config: { damping: 10, stiffness: 100, mass: 1 },
});
transformStr = `translate(-50%, -50%) scale(${scaleAnim * currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'scale') {
const scaleAnim = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${scaleAnim * currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideDown') {
const translateY = interpolate(frame, [el.startFrame, el.startFrame + duration], [-50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideLeft') {
const translateX = interpolate(frame, [el.startFrame, el.startFrame + duration], [50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'blur') {
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'spin') {
const spinDeg = interpolate(frame, [el.startFrame, el.startFrame + duration], [360, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot + spinDeg}deg)`;
} else if (type === 'flip') {
const flipDeg = interpolate(frame, [el.startFrame, el.startFrame + duration], [90, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot}deg) rotateY(${flipDeg}deg)`;
}
}
// --- OUT TRANSITIONS ---
if (el.transitionOut) {
const { type, duration } = el.transitionOut;
const outStart = el.endFrame - duration;
if (type === 'fade') {
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'slideUp') {
const translateY = interpolate(frame, [outStart, el.endFrame], [0, 50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideRight') {
const translateX = interpolate(frame, [outStart, el.endFrame], [0, 50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'scale' || type === 'bounce') {
const scaleAnim = interpolate(frame, [outStart, el.endFrame], [1, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${scaleAnim * currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideDown') {
const translateY = interpolate(frame, [outStart, el.endFrame], [0, -50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideLeft') {
const translateX = interpolate(frame, [outStart, el.endFrame], [0, -50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'blur') {
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'spin') {
const spinDeg = interpolate(frame, [outStart, el.endFrame], [0, 360], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot + spinDeg}deg)`;
} else if (type === 'flip') {
const flipDeg = interpolate(frame, [outStart, el.endFrame], [0, 90], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot}deg) rotateY(${flipDeg}deg)`;
}
}
// --- TYPEWRITER ---
if (el.type === 'text') {
if (el.transitionIn?.type === 'typewriter' && frame <= el.startFrame + el.transitionIn.duration) {
const lettersCount = el.content.length;
const visibleLetters = Math.floor(interpolate(frame, [el.startFrame, el.startFrame + el.transitionIn.duration], [0, lettersCount], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
}));
displayContent = el.content.substring(0, visibleLetters);
} else if (el.transitionOut?.type === 'typewriter' && frame >= el.endFrame - el.transitionOut.duration) {
const lettersCount = el.content.length;
const visibleLetters = Math.floor(interpolate(frame, [el.endFrame - el.transitionOut.duration, el.endFrame], [lettersCount, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
}));
displayContent = el.content.substring(0, visibleLetters);
}
}
// --- KEYFRAME ANIMATIONS ---
if (el.keyframes && el.keyframes.length >= 2) {
// ── Multi-keyframe engine ──
const resolved = resolveKeyframes(el.keyframes, frame, {
x: tempX ?? el.x,
y: tempY ?? el.y,
scale: currentScale,
opacity: opacity,
rotation: currentRot,
});
// Note: x/y are applied in CompositionElement via tempPositions, not in transformStr
// But scale, rotation, and opacity need to update transformStr
transformStr = `translate(-50%, -50%) scale(${resolved.scale}) rotate(${resolved.rotation}deg)`;
opacity = resolved.opacity;
// Return resolved x/y through the transform (CompositionElement reads these)
return { opacity, transformStr, displayContent };
}
// --- Legacy 2-point Keyframe Interpolations (backwards compatible) ---
if (el.animEndX !== undefined) {
interpolate(frame, [el.startFrame, el.endFrame], [tempX ?? el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
if (el.animEndY !== undefined) {
interpolate(frame, [el.startFrame, el.endFrame], [tempY ?? el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
if (el.animEndScale !== undefined) {
currentScale = interpolate(frame, [el.startFrame, el.endFrame], [currentScale, el.animEndScale], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
transformStr = transformStr.replace(/scale\([\d.]+\)/, `scale(${currentScale})`);
}
if (el.animEndOpacity !== undefined) {
opacity = opacity * interpolate(frame, [el.startFrame, el.endFrame], [1, el.animEndOpacity / 100], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
return { opacity, transformStr, displayContent };
}
@@ -0,0 +1,235 @@
import React, { useState, useMemo, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
import { ContentPiece, ContentPillar } from '../../types';
import { ContentCard } from './ContentCard';
interface CalendarViewProps {
pieces: ContentPiece[];
pillars: ContentPillar[];
onPieceClick: (piece: ContentPiece) => void;
onCreatePiece: (date: string) => void;
onDropPiece: (pieceId: string, newDate: string) => void;
}
const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
const MONTHS_ES = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
/**
* Monthly calendar view inspired by Later/Planable.
* Shows content pieces in day cells with drag-and-drop rescheduling.
*/
export const CalendarView: React.FC<CalendarViewProps> = ({
pieces,
pillars,
onPieceClick,
onCreatePiece,
onDropPiece,
}) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [dragOverDate, setDragOverDate] = useState<string | null>(null);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Generate calendar grid (6 weeks × 7 days)
const calendarDays = useMemo(() => {
const firstDay = new Date(year, month, 1);
// Adjust so Monday = 0
const startDow = (firstDay.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: { date: Date; isCurrentMonth: boolean }[] = [];
// Previous month fill
const prevMonthDays = new Date(year, month, 0).getDate();
for (let i = startDow - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthDays - i),
isCurrentMonth: false,
});
}
// Current month
for (let d = 1; d <= daysInMonth; d++) {
days.push({
date: new Date(year, month, d),
isCurrentMonth: true,
});
}
// Next month fill (to complete 6 rows)
const remaining = 42 - days.length;
for (let d = 1; d <= remaining; d++) {
days.push({
date: new Date(year, month + 1, d),
isCurrentMonth: false,
});
}
return days;
}, [year, month]);
// Group pieces by date
const piecesByDate = useMemo(() => {
const map: Record<string, ContentPiece[]> = {};
pieces.forEach(p => {
if (p.scheduledDate) {
const key = p.scheduledDate;
if (!map[key]) map[key] = [];
map[key].push(p);
}
});
return map;
}, [pieces]);
const toDateKey = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const isToday = (date: Date) => {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
};
const goToPrev = () => setCurrentDate(new Date(year, month - 1, 1));
const goToNext = () => setCurrentDate(new Date(year, month + 1, 1));
const goToToday = () => setCurrentDate(new Date());
const handleDragOver = useCallback((e: React.DragEvent, dateKey: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverDate(dateKey);
}, []);
const handleDrop = useCallback((e: React.DragEvent, dateKey: string) => {
e.preventDefault();
const pieceId = e.dataTransfer.getData('text/piece-id');
if (pieceId) {
onDropPiece(pieceId, dateKey);
}
setDragOverDate(null);
}, [onDropPiece]);
const handleDragStart = useCallback((e: React.DragEvent, piece: ContentPiece) => {
e.dataTransfer.setData('text/piece-id', piece.id);
e.dataTransfer.effectAllowed = 'move';
}, []);
return (
<div className="flex flex-col h-full">
{/* Calendar Header */}
<div className="flex items-center justify-between px-1 pb-4">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-white">
{MONTHS_ES[month]} {year}
</h3>
<button
onClick={goToToday}
className="px-2 py-1 text-[10px] font-semibold text-violet-400 bg-violet-600/10 border border-violet-500/20 rounded-lg hover:bg-violet-600/20 transition-all"
title="Ir a hoy"
>
Hoy
</button>
</div>
<div className="flex items-center gap-1">
<button
onClick={goToPrev}
className="p-1.5 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Mes anterior"
>
<ChevronLeft size={18} />
</button>
<button
onClick={goToNext}
className="p-1.5 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Mes siguiente"
>
<ChevronRight size={18} />
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-px mb-px">
{DAYS_ES.map(day => (
<div key={day} className="text-center text-[10px] font-semibold text-neutral-500 uppercase tracking-widest py-2">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-px flex-1 bg-neutral-800/30 rounded-xl overflow-hidden border border-neutral-800/50">
{calendarDays.map(({ date, isCurrentMonth }, idx) => {
const dateKey = toDateKey(date);
const dayPieces = piecesByDate[dateKey] || [];
const today = isToday(date);
const isDragOver = dragOverDate === dateKey;
return (
<div
key={idx}
className={`min-h-[100px] p-1.5 flex flex-col transition-colors ${
isCurrentMonth
? 'bg-neutral-950/80'
: 'bg-neutral-950/40'
} ${isDragOver ? 'bg-violet-950/30 ring-1 ring-inset ring-violet-500/40' : ''}`}
onDragOver={(e) => handleDragOver(e, dateKey)}
onDragLeave={() => setDragOverDate(null)}
onDrop={(e) => handleDrop(e, dateKey)}
>
{/* Day number */}
<div className="flex items-center justify-between mb-1">
<span
className={`text-[11px] font-semibold w-6 h-6 flex items-center justify-center rounded-full transition-colors ${
today
? 'bg-violet-600 text-white'
: isCurrentMonth
? 'text-neutral-300'
: 'text-neutral-700'
}`}
>
{date.getDate()}
</span>
{isCurrentMonth && (
<button
onClick={() => onCreatePiece(dateKey)}
className="w-4 h-4 rounded flex items-center justify-center text-neutral-700 hover:text-violet-400 hover:bg-violet-600/10 transition-all opacity-0 hover:opacity-100 focus:opacity-100"
title="Crear contenido en este día"
>
<Plus size={10} />
</button>
)}
</div>
{/* Content pieces */}
<div className="space-y-0.5 flex-1 overflow-y-auto custom-scrollbar">
{dayPieces.slice(0, 3).map(piece => (
<ContentCard
key={piece.id}
piece={piece}
pillar={pillars.find(p => p.id === piece.pillarId)}
onClick={onPieceClick}
compact
draggable
onDragStart={handleDragStart}
/>
))}
{dayPieces.length > 3 && (
<span className="text-[9px] text-neutral-600 font-mono px-1">
+{dayPieces.length - 3} más
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
+125
View File
@@ -0,0 +1,125 @@
import React from 'react';
import { ContentPiece, ContentPillar } from '../../types';
import { StatusBadge } from './StatusBadge';
import { PlatformIcons } from './PlatformIcons';
import { GripVertical, Calendar, MessageSquare } from 'lucide-react';
interface ContentCardProps {
piece: ContentPiece;
pillar?: ContentPillar;
onClick: (piece: ContentPiece) => void;
compact?: boolean;
draggable?: boolean;
onDragStart?: (e: React.DragEvent, piece: ContentPiece) => void;
}
/**
* Card component for a single content piece.
* Used across Calendar, Grid, and List views.
* Supports drag-and-drop for reorganization.
*/
export const ContentCard: React.FC<ContentCardProps> = ({
piece,
pillar,
onClick,
compact = false,
draggable = false,
onDragStart,
}) => {
if (compact) {
return (
<button
onClick={() => onClick(piece)}
draggable={draggable}
onDragStart={(e) => onDragStart?.(e, piece)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg bg-neutral-900/60 border border-neutral-800/50 hover:border-neutral-700 hover:bg-neutral-800/50 transition-all text-left group cursor-pointer"
title={piece.title}
>
{/* Pillar color dot */}
{pillar && (
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: pillar.color }}
/>
)}
<span className="text-[11px] font-medium text-neutral-300 truncate flex-1">
{piece.title}
</span>
<PlatformIcons platforms={piece.platforms} size="sm" max={2} />
</button>
);
}
return (
<div
onClick={() => onClick(piece)}
draggable={draggable}
onDragStart={(e) => onDragStart?.(e, piece)}
className="group bg-neutral-900/60 backdrop-blur-sm border border-neutral-800/50 rounded-xl p-4 hover:border-neutral-700 hover:bg-neutral-800/40 transition-all cursor-pointer relative overflow-hidden"
>
{/* Pillar color bar */}
{pillar && (
<div
className="absolute top-0 left-0 w-full h-0.5"
style={{ backgroundColor: pillar.color }}
/>
)}
{/* Drag handle */}
{draggable && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-neutral-600 cursor-grab">
<GripVertical size={14} />
</div>
)}
<div className="space-y-2.5">
{/* Title */}
<h4 className="text-sm font-semibold text-white leading-tight line-clamp-2 pr-4">
{piece.title}
</h4>
{/* Status + Pillar */}
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={piece.status} />
{pillar && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full"
style={{
color: pillar.color,
backgroundColor: `${pillar.color}15`,
}}
>
{pillar.name}
</span>
)}
</div>
{/* Description preview */}
{piece.description && (
<p className="text-[11px] text-neutral-500 line-clamp-2 leading-relaxed">
{piece.description}
</p>
)}
{/* Footer: platforms + date */}
<div className="flex items-center justify-between pt-1 border-t border-neutral-800/30">
<PlatformIcons platforms={piece.platforms} size="sm" />
<div className="flex items-center gap-2">
{piece.scheduledDate && (
<span className="flex items-center gap-1 text-[10px] text-neutral-500 font-mono">
<Calendar size={10} />
{new Date(piece.scheduledDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
})}
</span>
)}
{piece.notes && (
<MessageSquare size={10} className="text-neutral-600" title="Tiene notas" />
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,333 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Trash2, ExternalLink, Calendar, Clock, Hash, FileText, StickyNote } from 'lucide-react';
import { ContentPiece, ContentPillar, ContentStatus, Platform, Project } from '../../types';
import { StatusBadge } from './StatusBadge';
import { PlatformSelector } from './PlatformIcons';
import { ALL_STATUSES, STATUS_CONFIG } from '../../data/defaults';
interface ContentDetailModalProps {
piece: ContentPiece | null;
pillars: ContentPillar[];
projects: Project[];
onSave: (piece: ContentPiece) => void;
onDelete: (id: string) => void;
onClose: () => void;
onOpenProject?: (projectId: string) => void;
}
/**
* Modal for creating/editing a content piece.
* Contains all fields: title, description, status, pillar, platforms,
* scheduled date/time, caption, hashtags, and notes.
*/
export const ContentDetailModal: React.FC<ContentDetailModalProps> = ({
piece,
pillars,
projects,
onSave,
onDelete,
onClose,
onOpenProject,
}) => {
const isNew = !piece;
const [form, setForm] = useState<ContentPiece>(() => {
if (piece) return { ...piece };
return {
id: `content-${Date.now()}`,
companyId: '',
title: '',
status: 'idea' as ContentStatus,
platforms: ['instagram'] as Platform[],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
// Reset form when piece changes
useEffect(() => {
if (piece) setForm({ ...piece });
}, [piece?.id]);
const update = <K extends keyof ContentPiece>(key: K, value: ContentPiece[K]) => {
setForm(prev => ({ ...prev, [key]: value, updatedAt: new Date().toISOString() }));
};
const handleSave = () => {
if (!form.title.trim()) return;
onSave(form);
};
const handleHashtagInput = (raw: string) => {
const tags = raw
.split(/[,\s]+/)
.map(t => t.replace(/^#/, '').trim())
.filter(Boolean);
update('hashtags', tags);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-violet-500 animate-pulse" />
<h3 className="text-sm font-bold text-white">
{isNew ? 'Nueva Pieza de Contenido' : 'Editar Contenido'}
</h3>
</div>
<button
onClick={onClose}
className="p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-800 rounded-lg transition-colors"
title="Cerrar"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-5 custom-scrollbar">
{/* Title */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Título *
</label>
<input
type="text"
value={form.title}
onChange={(e) => update('title', e.target.value)}
placeholder="¿De qué trata este contenido?"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-sm text-white font-medium placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all"
autoFocus
/>
</div>
{/* Status + Pillar row */}
<div className="grid grid-cols-2 gap-4">
{/* Status */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Estado
</label>
<div className="flex flex-wrap gap-1">
{ALL_STATUSES.map(s => {
const cfg = STATUS_CONFIG[s];
const isActive = form.status === s;
return (
<button
key={s}
onClick={() => update('status', s)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400 hover:border-neutral-700'
}`}
style={
isActive
? { backgroundColor: cfg.bgColor, borderColor: `${cfg.color}50`, color: cfg.color }
: undefined
}
title={cfg.label}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cfg.color }} />
{cfg.label}
</button>
);
})}
</div>
</div>
{/* Pillar */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Pilar de Contenido
</label>
<div className="flex flex-wrap gap-1">
<button
onClick={() => update('pillarId', undefined)}
className={`px-2 py-1.5 rounded-lg text-[10px] font-medium transition-all border ${
!form.pillarId
? 'bg-neutral-800 border-neutral-700 text-neutral-300'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400'
}`}
>
Sin pilar
</button>
{pillars.map(p => {
const isActive = form.pillarId === p.id;
return (
<button
key={p.id}
onClick={() => update('pillarId', p.id)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400'
}`}
style={
isActive
? { backgroundColor: `${p.color}15`, borderColor: `${p.color}50`, color: p.color }
: undefined
}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: p.color }} />
{p.name}
</button>
);
})}
</div>
</div>
</div>
{/* Platforms */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Plataformas Destino
</label>
<PlatformSelector
selected={form.platforms}
onChange={(platforms) => update('platforms', platforms)}
/>
</div>
{/* Schedule */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<Calendar size={10} /> Fecha Programada
</label>
<input
type="date"
value={form.scheduledDate || ''}
onChange={(e) => update('scheduledDate', e.target.value || undefined)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-violet-500/50 transition-all [color-scheme:dark]"
/>
</div>
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<Clock size={10} /> Hora
</label>
<input
type="time"
value={form.scheduledTime || ''}
onChange={(e) => update('scheduledTime', e.target.value || undefined)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-violet-500/50 transition-all [color-scheme:dark]"
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<FileText size={10} /> Descripción
</label>
<textarea
value={form.description || ''}
onChange={(e) => update('description', e.target.value)}
placeholder="Describe el contenido, contexto, o idea..."
rows={3}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all resize-none"
/>
</div>
{/* Caption */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Caption / Copy del Post
</label>
<textarea
value={form.caption || ''}
onChange={(e) => update('caption', e.target.value)}
placeholder="El texto que acompañará la publicación..."
rows={3}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all resize-none font-mono"
/>
</div>
{/* Hashtags */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<Hash size={10} /> Hashtags
</label>
<input
type="text"
value={(form.hashtags || []).map(t => `#${t}`).join(' ')}
onChange={(e) => handleHashtagInput(e.target.value)}
placeholder="#marketing #socialmedia #brand"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-all font-mono"
/>
</div>
{/* Notes */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<StickyNote size={10} /> Notas Internas
</label>
<textarea
value={form.notes || ''}
onChange={(e) => update('notes', e.target.value)}
placeholder="Notas para el equipo..."
rows={2}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all resize-none"
/>
</div>
{/* Linked Project */}
{form.projectId && (
<div className="bg-neutral-800/30 border border-neutral-800 rounded-xl p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<ExternalLink size={12} className="text-violet-400" />
<span className="text-xs text-neutral-300 font-medium">
Proyecto vinculado: {projects.find(p => p.id === form.projectId)?.name || form.projectId}
</span>
</div>
{onOpenProject && (
<button
onClick={() => onOpenProject(form.projectId!)}
className="text-[10px] text-violet-400 hover:text-violet-300 font-medium"
title="Abrir en Studio"
>
Abrir en Studio
</button>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-neutral-800 bg-neutral-900/80">
<div>
{!isNew && (
<button
onClick={() => onDelete(form.id)}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-neutral-500 hover:text-rose-400 rounded-lg hover:bg-rose-950/20 transition-all"
title="Eliminar contenido"
>
<Trash2 size={13} /> Eliminar
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={!form.title.trim()}
className="flex items-center gap-1.5 px-4 py-2 text-xs font-semibold bg-violet-600 hover:bg-violet-500 text-white rounded-lg transition-colors shadow-lg shadow-violet-900/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Save size={13} /> {isNew ? 'Crear' : 'Guardar'}
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,164 @@
import React from 'react';
import { ContentStatus, Platform, ContentPillar } from '../../types';
import { STATUS_CONFIG, PLATFORM_CONFIG, ALL_STATUSES, ALL_PLATFORMS } from '../../data/defaults';
import { Filter, X } from 'lucide-react';
interface ContentFiltersProps {
pillars: ContentPillar[];
selectedPillar: string | null;
onPillarChange: (id: string | null) => void;
selectedStatus: ContentStatus | null;
onStatusChange: (status: ContentStatus | null) => void;
selectedPlatform: Platform | null;
onPlatformChange: (platform: Platform | null) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
}
/**
* Filter bar for the content grid.
* Allows filtering by pillar, status, platform, and free-text search.
*/
export const ContentFilters: React.FC<ContentFiltersProps> = ({
pillars,
selectedPillar,
onPillarChange,
selectedStatus,
onStatusChange,
selectedPlatform,
onPlatformChange,
searchQuery,
onSearchChange,
}) => {
const hasFilters = selectedPillar || selectedStatus || selectedPlatform || searchQuery;
return (
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Buscar contenido..."
className="w-full bg-neutral-900/60 border border-neutral-800 rounded-lg px-3 py-2 pl-8 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all"
/>
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-600" />
</div>
{/* Pillar filter */}
<div className="flex items-center gap-1">
<button
onClick={() => onPillarChange(null)}
className={`px-2 py-1.5 rounded-lg text-[10px] font-semibold transition-all border ${
!selectedPillar
? 'bg-violet-600/15 border-violet-500/30 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
title="Todos los pilares"
>
Todos
</button>
{pillars.map((p) => (
<button
key={p.id}
onClick={() => onPillarChange(selectedPillar === p.id ? null : p.id)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-semibold transition-all border ${
selectedPillar === p.id
? 'border-opacity-60 text-white'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
style={
selectedPillar === p.id
? { backgroundColor: `${p.color}20`, borderColor: `${p.color}50`, color: p.color }
: undefined
}
title={`Pilar: ${p.name}`}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: p.color }} />
{p.name}
</button>
))}
</div>
{/* Clear all */}
{hasFilters && (
<button
onClick={() => {
onPillarChange(null);
onStatusChange(null);
onPlatformChange(null);
onSearchChange('');
}}
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-medium text-neutral-500 hover:text-rose-400 border border-neutral-800 hover:border-rose-500/30 transition-all"
title="Limpiar filtros"
>
<X size={10} /> Limpiar
</button>
)}
</div>
{/* Second row: Status + Platform */}
<div className="flex items-center gap-3 flex-wrap">
{/* Status chips */}
<div className="flex items-center gap-1">
<span className="text-[9px] font-semibold text-neutral-600 uppercase tracking-wider mr-1">Estado:</span>
{ALL_STATUSES.map((s) => {
const cfg = STATUS_CONFIG[s];
const isActive = selectedStatus === s;
return (
<button
key={s}
onClick={() => onStatusChange(isActive ? null : s)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400 hover:border-neutral-700'
}`}
style={
isActive
? { backgroundColor: cfg.bgColor, borderColor: `${cfg.color}50`, color: cfg.color }
: undefined
}
title={`Filtrar por: ${cfg.label}`}
>
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: cfg.color }} />
{cfg.label}
</button>
);
})}
</div>
{/* Platform chips */}
<div className="flex items-center gap-1">
<span className="text-[9px] font-semibold text-neutral-600 uppercase tracking-wider mr-1">Red:</span>
{ALL_PLATFORMS.map((p) => {
const cfg = PLATFORM_CONFIG[p];
const isActive = selectedPlatform === p;
return (
<button
key={p}
onClick={() => onPlatformChange(isActive ? null : p)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400 hover:border-neutral-700'
}`}
style={
isActive
? { backgroundColor: `${cfg.color}15`, borderColor: `${cfg.color}50`, color: cfg.color }
: undefined
}
title={`Filtrar por: ${cfg.label}`}
>
<span className="text-[10px]">{cfg.icon}</span>
{cfg.label}
</button>
);
})}
</div>
</div>
</div>
);
};
@@ -0,0 +1,321 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
CalendarDays, LayoutGrid, List, Plus, Settings2, Sparkles,
BarChart3, TrendingUp
} from 'lucide-react';
import {
ContentPiece, ContentPillar, ContentStatus, Platform, CompanyProfile
} from '../../types';
import { DEFAULT_PILLARS } from '../../data/defaults';
import { ContentFilters } from './ContentFilters';
import { CalendarView } from './CalendarView';
import { GridView } from './GridView';
import { ListView } from './ListView';
import { ContentDetailModal } from './ContentDetailModal';
import { PillarManager } from './PillarManager';
type ViewMode = 'calendar' | 'grid' | 'list';
interface ContentGridViewProps {
company: CompanyProfile;
pieces: ContentPiece[];
pillars: ContentPillar[];
onPiecesChange: (pieces: ContentPiece[]) => void;
onPillarsChange: (pillars: ContentPillar[]) => void;
onOpenProject: (projectId: string) => void;
}
/**
* Main content grid view with three visualization modes.
* Orchestrates Calendar, Grid, and List views with shared filters.
*/
export const ContentGridView: React.FC<ContentGridViewProps> = ({
company,
pieces,
pillars,
onPiecesChange,
onPillarsChange,
onOpenProject,
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('calendar');
const [showSettings, setShowSettings] = useState(false);
const [editingPiece, setEditingPiece] = useState<ContentPiece | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createDate, setCreateDate] = useState<string | undefined>();
// Grid view state
const [gridPlatform, setGridPlatform] = useState<Platform>('instagram');
// Filters
const [selectedPillar, setSelectedPillar] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<ContentStatus | null>(null);
const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Filter pieces
const filteredPieces = useMemo(() => {
return pieces.filter(p => {
if (selectedPillar && p.pillarId !== selectedPillar) return false;
if (selectedStatus && p.status !== selectedStatus) return false;
if (selectedPlatform && !p.platforms.includes(selectedPlatform)) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const matches =
p.title.toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
(p.caption || '').toLowerCase().includes(q);
if (!matches) return false;
}
return true;
});
}, [pieces, selectedPillar, selectedStatus, selectedPlatform, searchQuery]);
// Stats
const stats = useMemo(() => {
const total = pieces.length;
const scheduled = pieces.filter(p => p.status === 'scheduled').length;
const published = pieces.filter(p => p.status === 'published').length;
const thisWeek = pieces.filter(p => {
if (!p.scheduledDate) return false;
const d = new Date(p.scheduledDate);
const now = new Date();
const weekEnd = new Date(now);
weekEnd.setDate(weekEnd.getDate() + 7);
return d >= now && d <= weekEnd;
}).length;
return { total, scheduled, published, thisWeek };
}, [pieces]);
// Handlers
const handleCreatePiece = useCallback((date?: string) => {
setCreateDate(date);
setEditingPiece(null);
setShowCreateModal(true);
}, []);
const handleSavePiece = useCallback((piece: ContentPiece) => {
piece.companyId = company.id;
const exists = pieces.find(p => p.id === piece.id);
if (exists) {
onPiecesChange(pieces.map(p => p.id === piece.id ? piece : p));
} else {
// Apply the pre-set date if creating from calendar
if (createDate && !piece.scheduledDate) {
piece.scheduledDate = createDate;
if (piece.status === 'idea') piece.status = 'draft';
}
onPiecesChange([...pieces, piece]);
}
setEditingPiece(null);
setShowCreateModal(false);
setCreateDate(undefined);
}, [pieces, company.id, onPiecesChange, createDate]);
const handleDeletePiece = useCallback((id: string) => {
onPiecesChange(pieces.filter(p => p.id !== id));
setEditingPiece(null);
setShowCreateModal(false);
}, [pieces, onPiecesChange]);
const handleDropPiece = useCallback((pieceId: string, newDate: string) => {
onPiecesChange(pieces.map(p =>
p.id === pieceId
? { ...p, scheduledDate: newDate, updatedAt: new Date().toISOString() }
: p
));
}, [pieces, onPiecesChange]);
const handleStatusChange = useCallback((pieceId: string, newStatus: ContentStatus) => {
onPiecesChange(pieces.map(p =>
p.id === pieceId
? { ...p, status: newStatus, updatedAt: new Date().toISOString() }
: p
));
}, [pieces, onPiecesChange]);
const handlePieceClick = useCallback((piece: ContentPiece) => {
setEditingPiece(piece);
setShowCreateModal(true);
}, []);
return (
<div className="flex-1 overflow-hidden flex flex-col w-full relative bg-neutral-950">
{/* Background pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
/>
<div className="relative z-10 flex-1 flex flex-col overflow-hidden p-6">
{/* ═══ Header ═══ */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-violet-900/20">
<CalendarDays size={18} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-white tracking-tight">Malla de Contenidos</h1>
<p className="text-[11px] text-neutral-500">
{company.name} · {filteredPieces.length} de {pieces.length} piezas
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Stats mini-bar */}
<div className="hidden md:flex items-center gap-3 mr-3">
<StatPill label="Esta semana" value={stats.thisWeek} icon={<TrendingUp size={10} />} color="#a78bfa" />
<StatPill label="Programados" value={stats.scheduled} icon={<CalendarDays size={10} />} color="#60a5fa" />
<StatPill label="Publicados" value={stats.published} icon={<BarChart3 size={10} />} color="#22c55e" />
</div>
{/* Settings button */}
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-lg transition-all border ${
showSettings
? 'bg-violet-600/15 border-violet-500/30 text-violet-300'
: 'bg-neutral-900/60 border-neutral-800 text-neutral-500 hover:text-white hover:border-neutral-700'
}`}
title="Configurar Pilares"
>
<Settings2 size={16} />
</button>
{/* New content CTA */}
<button
onClick={() => handleCreatePiece()}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white rounded-xl text-xs font-semibold transition-all shadow-lg shadow-violet-900/20 hover:shadow-violet-900/40 hover:scale-[1.02] active:scale-[0.98]"
>
<Plus size={14} /> Nuevo Contenido
</button>
</div>
</div>
{/* ═══ Settings Panel (Pillar Manager) ═══ */}
{showSettings && (
<div className="mb-5 bg-neutral-900/40 border border-neutral-800/50 rounded-xl p-4 animate-in fade-in-0 slide-in-from-top-2 duration-200">
<PillarManager pillars={pillars} onChange={onPillarsChange} />
</div>
)}
{/* ═══ View Mode Toggle + Filters ═══ */}
<div className="flex items-start justify-between gap-4 mb-5">
{/* Filters */}
<div className="flex-1 min-w-0">
<ContentFilters
pillars={pillars}
selectedPillar={selectedPillar}
onPillarChange={setSelectedPillar}
selectedStatus={selectedStatus}
onStatusChange={setSelectedStatus}
selectedPlatform={selectedPlatform}
onPlatformChange={setSelectedPlatform}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
</div>
{/* View toggle */}
<div className="flex bg-neutral-900 border border-neutral-800 rounded-xl p-0.5 shrink-0">
{([
{ id: 'calendar' as ViewMode, icon: <CalendarDays size={14} />, label: 'Calendario' },
{ id: 'grid' as ViewMode, icon: <LayoutGrid size={14} />, label: 'Grid' },
{ id: 'list' as ViewMode, icon: <List size={14} />, label: 'Lista' },
]).map(v => (
<button
key={v.id}
onClick={() => setViewMode(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
viewMode === v.id
? 'bg-neutral-800 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
title={v.label}
>
{v.icon}
<span className="hidden sm:inline">{v.label}</span>
</button>
))}
</div>
</div>
{/* ═══ View Content ═══ */}
<div className="flex-1 overflow-hidden">
{viewMode === 'calendar' && (
<CalendarView
pieces={filteredPieces}
pillars={pillars}
onPieceClick={handlePieceClick}
onCreatePiece={(date) => handleCreatePiece(date)}
onDropPiece={handleDropPiece}
/>
)}
{viewMode === 'grid' && (
<GridView
pieces={filteredPieces}
pillars={pillars}
onPieceClick={handlePieceClick}
platform={gridPlatform}
onPlatformChange={setGridPlatform}
/>
)}
{viewMode === 'list' && (
<ListView
pieces={filteredPieces}
pillars={pillars}
onPieceClick={handlePieceClick}
onStatusChange={handleStatusChange}
/>
)}
{/* Empty state */}
{filteredPieces.length === 0 && pieces.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-neutral-600">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-600/10 to-fuchsia-600/10 border border-violet-500/10 flex items-center justify-center mb-4">
<Sparkles size={28} className="text-violet-500/40" />
</div>
<h3 className="text-sm font-semibold text-neutral-400 mb-1">Tu malla está vacía</h3>
<p className="text-xs text-neutral-600 text-center max-w-xs mb-4">
Empieza a planificar tu contenido creando piezas y organizándolas en el calendario
</p>
<button
onClick={() => handleCreatePiece()}
className="flex items-center gap-1.5 px-4 py-2 bg-violet-600/15 hover:bg-violet-600/25 text-violet-400 text-xs font-semibold rounded-xl border border-violet-500/20 hover:border-violet-500/40 transition-all"
>
<Plus size={14} /> Crear primera pieza
</button>
</div>
)}
</div>
</div>
{/* ═══ Content Detail Modal ═══ */}
{showCreateModal && (
<ContentDetailModal
piece={editingPiece}
pillars={pillars}
projects={company.projects || []}
onSave={handleSavePiece}
onDelete={handleDeletePiece}
onClose={() => { setShowCreateModal(false); setEditingPiece(null); setCreateDate(undefined); }}
onOpenProject={onOpenProject}
/>
)}
</div>
);
};
/** Mini stat pill for the header */
const StatPill: React.FC<{ label: string; value: number; icon: React.ReactNode; color: string }> = ({
label, value, icon, color,
}) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-lg border text-[10px] font-medium"
style={{ borderColor: `${color}20`, color, backgroundColor: `${color}08` }}
>
{icon}
<span className="font-bold">{value}</span>
<span className="opacity-60">{label}</span>
</div>
);
+162
View File
@@ -0,0 +1,162 @@
import React, { useMemo, useCallback } from 'react';
import { ContentPiece, ContentPillar, Platform } from '../../types';
import { PlatformIcons } from './PlatformIcons';
import { StatusBadge } from './StatusBadge';
import { Image as ImageIcon, Video, Instagram } from 'lucide-react';
interface GridViewProps {
pieces: ContentPiece[];
pillars: ContentPillar[];
onPieceClick: (piece: ContentPiece) => void;
platform: Platform;
onPlatformChange: (platform: Platform) => void;
}
/**
* Visual grid view inspired by Later/Instagram feed planner.
* Shows content as a 3-column grid preview mimicking how it will look on a social feed.
*/
export const GridView: React.FC<GridViewProps> = ({
pieces,
pillars,
onPieceClick,
platform,
onPlatformChange,
}) => {
// Filter pieces that target the selected platform and are scheduled/published
const gridPieces = useMemo(() => {
return pieces
.filter(p => p.platforms.includes(platform))
.sort((a, b) => {
// Sort by scheduled date, most recent first
const dateA = a.scheduledDate || a.createdAt;
const dateB = b.scheduledDate || b.createdAt;
return dateB.localeCompare(dateA);
});
}, [pieces, platform]);
const columns = platform === 'tiktok' || platform === 'youtube' ? 2 : 3;
return (
<div className="flex flex-col h-full">
{/* Platform selector */}
<div className="flex items-center gap-3 pb-4">
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest">
Vista de Feed:
</span>
<div className="flex gap-1">
{(['instagram', 'tiktok', 'facebook', 'linkedin'] as Platform[]).map(p => {
const isActive = platform === p;
return (
<button
key={p}
onClick={() => onPlatformChange(p)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${
isActive
? 'bg-violet-600/15 border-violet-500/30 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
title={`Vista de ${p}`}
>
{p === 'instagram' && '📸'}
{p === 'tiktok' && '🎵'}
{p === 'facebook' && '📘'}
{p === 'linkedin' && '💼'}
{p.charAt(0).toUpperCase() + p.slice(1)}
</button>
);
})}
</div>
</div>
{/* Feed preview container */}
<div className="flex-1 flex justify-center overflow-y-auto">
<div
className="bg-neutral-900/30 border border-neutral-800/50 rounded-2xl p-4 max-w-lg w-full"
style={{ maxWidth: columns === 2 ? '380px' : '480px' }}
>
{/* Fake profile header */}
<div className="flex items-center gap-3 mb-4 px-1">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500" />
<div>
<p className="text-xs font-semibold text-white">@mi_marca</p>
<p className="text-[10px] text-neutral-500">{gridPieces.length} publicaciones</p>
</div>
</div>
{/* Grid */}
{gridPieces.length > 0 ? (
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
>
{gridPieces.map(piece => {
const pillar = pillars.find(p => p.id === piece.pillarId);
return (
<button
key={piece.id}
onClick={() => onPieceClick(piece)}
className="relative aspect-square bg-neutral-800 rounded-sm overflow-hidden group hover:opacity-90 transition-all"
>
{/* Thumbnail or placeholder */}
{piece.thumbnailUrl ? (
<img
src={piece.thumbnailUrl}
alt={piece.title}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex flex-col items-center justify-center p-2"
style={{ backgroundColor: pillar ? `${pillar.color}15` : '#1a1a2e' }}
>
<div className="text-neutral-600 mb-1">
{piece.projectId ? <Video size={16} /> : <ImageIcon size={16} />}
</div>
<span className="text-[8px] text-neutral-500 text-center line-clamp-2 leading-tight">
{piece.title}
</span>
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 p-2">
<span className="text-[10px] font-semibold text-white text-center line-clamp-2">
{piece.title}
</span>
<StatusBadge status={piece.status} size="sm" />
{piece.scheduledDate && (
<span className="text-[9px] text-neutral-400 font-mono">
{new Date(piece.scheduledDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
})}
</span>
)}
</div>
{/* Pillar indicator */}
{pillar && (
<div
className="absolute top-1 right-1 w-2 h-2 rounded-full ring-1 ring-black/30"
style={{ backgroundColor: pillar.color }}
/>
)}
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-neutral-600">
<Instagram size={32} className="mb-3 opacity-30" />
<p className="text-xs font-medium">No hay contenido para {platform}</p>
<p className="text-[10px] text-neutral-700 mt-1">
Crea piezas de contenido y asígnalas a esta plataforma
</p>
</div>
)}
</div>
</div>
</div>
);
};
+206
View File
@@ -0,0 +1,206 @@
import React, { useMemo, useCallback, useState } from 'react';
import { ContentPiece, ContentPillar, ContentStatus } from '../../types';
import { ContentCard } from './ContentCard';
import { STATUS_CONFIG, ALL_STATUSES } from '../../data/defaults';
import { Columns3, List as ListIcon } from 'lucide-react';
interface ListViewProps {
pieces: ContentPiece[];
pillars: ContentPillar[];
onPieceClick: (piece: ContentPiece) => void;
onStatusChange: (pieceId: string, newStatus: ContentStatus) => void;
}
type ListMode = 'kanban' | 'list';
/**
* List/Kanban view for content pieces.
* Kanban: columns per status with drag-and-drop between columns.
* List: simple scrollable list grouped by status.
*/
export const ListView: React.FC<ListViewProps> = ({
pieces,
pillars,
onPieceClick,
onStatusChange,
}) => {
const [mode, setMode] = useState<ListMode>('kanban');
const [dragOverStatus, setDragOverStatus] = useState<ContentStatus | null>(null);
// Group pieces by status
const groupedPieces = useMemo(() => {
const map: Record<ContentStatus, ContentPiece[]> = {
'idea': [],
'draft': [],
'in-review': [],
'approved': [],
'scheduled': [],
'published': [],
};
pieces.forEach(p => {
map[p.status].push(p);
});
return map;
}, [pieces]);
const handleDragStart = useCallback((e: React.DragEvent, piece: ContentPiece) => {
e.dataTransfer.setData('text/piece-id', piece.id);
e.dataTransfer.effectAllowed = 'move';
}, []);
const handleDragOver = useCallback((e: React.DragEvent, status: ContentStatus) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverStatus(status);
}, []);
const handleDrop = useCallback((e: React.DragEvent, status: ContentStatus) => {
e.preventDefault();
const pieceId = e.dataTransfer.getData('text/piece-id');
if (pieceId) {
onStatusChange(pieceId, status);
}
setDragOverStatus(null);
}, [onStatusChange]);
if (mode === 'list') {
return (
<div className="flex flex-col h-full">
{/* Mode toggle */}
<div className="flex items-center justify-end pb-3">
<ModeToggle mode={mode} setMode={setMode} />
</div>
<div className="flex-1 overflow-y-auto space-y-6 pr-1 custom-scrollbar">
{ALL_STATUSES.map(status => {
const statusPieces = groupedPieces[status];
if (statusPieces.length === 0) return null;
const cfg = STATUS_CONFIG[status];
return (
<div key={status}>
<div className="flex items-center gap-2 mb-2.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: cfg.color }} />
<h4 className="text-xs font-semibold uppercase tracking-widest" style={{ color: cfg.color }}>
{cfg.label}
</h4>
<span className="text-[10px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{statusPieces.length}
</span>
</div>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
{statusPieces.map(piece => (
<ContentCard
key={piece.id}
piece={piece}
pillar={pillars.find(p => p.id === piece.pillarId)}
onClick={onPieceClick}
/>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
// Kanban mode
return (
<div className="flex flex-col h-full">
{/* Mode toggle */}
<div className="flex items-center justify-end pb-3">
<ModeToggle mode={mode} setMode={setMode} />
</div>
{/* Kanban columns */}
<div className="flex-1 flex gap-3 overflow-x-auto pb-2 custom-scrollbar">
{ALL_STATUSES.map(status => {
const cfg = STATUS_CONFIG[status];
const statusPieces = groupedPieces[status];
const isDragOver = dragOverStatus === status;
return (
<div
key={status}
className={`flex-shrink-0 w-[260px] flex flex-col rounded-xl border transition-colors ${
isDragOver
? 'border-violet-500/40 bg-violet-950/20'
: 'border-neutral-800/50 bg-neutral-900/30'
}`}
onDragOver={(e) => handleDragOver(e, status)}
onDragLeave={() => setDragOverStatus(null)}
onDrop={(e) => handleDrop(e, status)}
>
{/* Column header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-neutral-800/30">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: cfg.color }}
/>
<span
className="text-[11px] font-semibold uppercase tracking-wider"
style={{ color: cfg.color }}
>
{cfg.label}
</span>
<span className="ml-auto text-[10px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{statusPieces.length}
</span>
</div>
{/* Column body */}
<div className="flex-1 p-2 space-y-2 overflow-y-auto custom-scrollbar min-h-[120px]">
{statusPieces.map(piece => (
<ContentCard
key={piece.id}
piece={piece}
pillar={pillars.find(p => p.id === piece.pillarId)}
onClick={onPieceClick}
draggable
onDragStart={handleDragStart}
/>
))}
{statusPieces.length === 0 && (
<div className="flex items-center justify-center h-20 text-neutral-700 text-[10px] font-medium border border-dashed border-neutral-800 rounded-lg">
Arrastra aquí
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
/** Toggle between kanban and list modes */
const ModeToggle: React.FC<{ mode: ListMode; setMode: (m: ListMode) => void }> = ({ mode, setMode }) => (
<div className="flex bg-neutral-900 border border-neutral-800 rounded-lg p-0.5">
<button
onClick={() => setMode('kanban')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
mode === 'kanban'
? 'bg-neutral-800 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
title="Vista Kanban"
>
<Columns3 size={12} /> Kanban
</button>
<button
onClick={() => setMode('list')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
mode === 'list'
? 'bg-neutral-800 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
title="Vista Lista"
>
<ListIcon size={12} /> Lista
</button>
</div>
);
@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { ContentPillar } from '../../types';
import { Plus, Trash2, GripVertical, Pencil, Check, X } from 'lucide-react';
interface PillarManagerProps {
pillars: ContentPillar[];
onChange: (pillars: ContentPillar[]) => void;
}
const PRESET_COLORS = [
'#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#22c55e',
'#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#e11d48',
];
/**
* CRUD manager for content pillars.
* Allows creating, editing, and deleting pillars with color pickers.
*/
export const PillarManager: React.FC<PillarManagerProps> = ({ pillars, onChange }) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState(PRESET_COLORS[0]);
const [showAdd, setShowAdd] = useState(false);
const handleAdd = () => {
if (!newName.trim()) return;
const pillar: ContentPillar = {
id: `pillar-${Date.now()}`,
name: newName.trim(),
color: newColor,
};
onChange([...pillars, pillar]);
setNewName('');
setNewColor(PRESET_COLORS[(pillars.length + 1) % PRESET_COLORS.length]);
setShowAdd(false);
};
const handleDelete = (id: string) => {
onChange(pillars.filter(p => p.id !== id));
};
const handleStartEdit = (pillar: ContentPillar) => {
setEditingId(pillar.id);
setEditName(pillar.name);
};
const handleSaveEdit = (id: string) => {
if (!editName.trim()) return;
onChange(pillars.map(p => p.id === id ? { ...p, name: editName.trim() } : p));
setEditingId(null);
};
const handleColorChange = (id: string, color: string) => {
onChange(pillars.map(p => p.id === id ? { ...p, color } : p));
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-widest flex items-center gap-2">
Pilares de Contenido
<span className="text-[10px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{pillars.length}
</span>
</h4>
<button
onClick={() => setShowAdd(!showAdd)}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-violet-400 hover:text-violet-300 bg-violet-600/10 hover:bg-violet-600/20 border border-violet-500/20 hover:border-violet-500/40 transition-all"
title="Agregar pilar"
>
<Plus size={12} /> Nuevo Pilar
</button>
</div>
{/* Add form */}
{showAdd && (
<div className="bg-neutral-900/60 border border-violet-500/20 rounded-xl p-3 space-y-3 animate-in fade-in-0 slide-in-from-top-2 duration-200">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
placeholder="Nombre del pilar (ej. Educativo)"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-all"
autoFocus
/>
<div className="flex items-center gap-2">
<span className="text-[10px] text-neutral-500 shrink-0">Color:</span>
<div className="flex gap-1 flex-wrap">
{PRESET_COLORS.map(c => (
<button
key={c}
onClick={() => setNewColor(c)}
className={`w-5 h-5 rounded-full border-2 transition-all ${
newColor === c ? 'border-white scale-110' : 'border-transparent hover:border-neutral-600'
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowAdd(false)}
className="px-3 py-1.5 text-[10px] font-medium text-neutral-500 hover:text-white rounded-lg hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={handleAdd}
disabled={!newName.trim()}
className="px-3 py-1.5 text-[10px] font-semibold bg-violet-600 hover:bg-violet-500 text-white rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1"
>
<Check size={12} /> Crear
</button>
</div>
</div>
)}
{/* Pillar list */}
<div className="space-y-1.5">
{pillars.map(pillar => (
<div
key={pillar.id}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-neutral-900/40 border border-neutral-800/50 hover:border-neutral-700 group transition-all"
>
{/* Color dot (editable) */}
<div className="relative">
<input
type="color"
value={pillar.color}
onChange={(e) => handleColorChange(pillar.id, e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-4 h-4"
title="Cambiar color"
/>
<div
className="w-3 h-3 rounded-full cursor-pointer ring-2 ring-transparent hover:ring-white/30 transition-all"
style={{ backgroundColor: pillar.color }}
/>
</div>
{/* Name (editable) */}
{editingId === pillar.id ? (
<div className="flex items-center gap-1 flex-1">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(pillar.id);
if (e.key === 'Escape') setEditingId(null);
}}
className="flex-1 bg-neutral-950 border border-neutral-700 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-violet-500"
autoFocus
/>
<button
onClick={() => handleSaveEdit(pillar.id)}
title="Guardar"
className="p-1 text-emerald-400 hover:text-emerald-300"
>
<Check size={12} />
</button>
<button
onClick={() => setEditingId(null)}
title="Cancelar"
className="p-1 text-neutral-500 hover:text-white"
>
<X size={12} />
</button>
</div>
) : (
<>
<span className="flex-1 text-xs font-medium text-neutral-300">{pillar.name}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleStartEdit(pillar)}
title="Editar pilar"
className="p-1 text-neutral-500 hover:text-violet-400 transition-colors"
>
<Pencil size={11} />
</button>
<button
onClick={() => handleDelete(pillar.id)}
title="Eliminar pilar"
className="p-1 text-neutral-500 hover:text-rose-400 transition-colors"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
))}
{pillars.length === 0 && (
<div className="text-center py-4 text-neutral-600 text-xs">
No hay pilares definidos. Crea uno para organizar tu contenido.
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,101 @@
import React from 'react';
import { Platform } from '../../types';
import { PLATFORM_CONFIG } from '../../data/defaults';
interface PlatformIconsProps {
platforms: Platform[];
size?: 'sm' | 'md';
max?: number;
}
/**
* Renders a row of social platform emoji icons with tooltips.
* Truncates to `max` items and shows "+N" overflow.
*/
export const PlatformIcons: React.FC<PlatformIconsProps> = ({
platforms,
size = 'sm',
max = 4,
}) => {
const visible = platforms.slice(0, max);
const overflow = platforms.length - max;
return (
<div className="flex items-center gap-0.5">
{visible.map(p => {
const cfg = PLATFORM_CONFIG[p];
return (
<span
key={p}
title={cfg.label}
className={`inline-flex items-center justify-center rounded-md transition-transform hover:scale-110 ${
size === 'sm' ? 'w-5 h-5 text-[11px]' : 'w-6 h-6 text-sm'
}`}
style={{ backgroundColor: `${cfg.color}15` }}
>
{cfg.icon}
</span>
);
})}
{overflow > 0 && (
<span
className={`inline-flex items-center justify-center rounded-md bg-neutral-800 text-neutral-500 font-mono font-semibold ${
size === 'sm' ? 'w-5 h-5 text-[9px]' : 'w-6 h-6 text-[10px]'
}`}
title={platforms.slice(max).map(p => PLATFORM_CONFIG[p].label).join(', ')}
>
+{overflow}
</span>
)}
</div>
);
};
interface PlatformSelectorProps {
selected: Platform[];
onChange: (platforms: Platform[]) => void;
}
/**
* Multi-select toggle for choosing target platforms.
*/
export const PlatformSelector: React.FC<PlatformSelectorProps> = ({ selected, onChange }) => {
const toggle = (p: Platform) => {
onChange(
selected.includes(p)
? selected.filter(x => x !== p)
: [...selected, p]
);
};
return (
<div className="flex flex-wrap gap-1.5">
{(Object.entries(PLATFORM_CONFIG) as [Platform, typeof PLATFORM_CONFIG[Platform]][]).map(
([key, cfg]) => {
const isActive = selected.includes(key);
return (
<button
key={key}
type="button"
onClick={() => toggle(key)}
title={cfg.label}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all ${
isActive
? 'border-opacity-60 text-white shadow-sm'
: 'bg-neutral-950/50 border-neutral-800 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300'
}`}
style={
isActive
? { backgroundColor: `${cfg.color}20`, borderColor: `${cfg.color}60`, color: cfg.color }
: undefined
}
>
<span className="text-sm">{cfg.icon}</span>
{cfg.label}
</button>
);
}
)}
</div>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { ContentStatus } from '../../types';
import { STATUS_CONFIG } from '../../data/defaults';
interface StatusBadgeProps {
status: ContentStatus;
size?: 'sm' | 'md';
onClick?: () => void;
}
/**
* Color-coded badge for content workflow status.
* Displays the localized label with a matching pill color.
*/
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, size = 'sm', onClick }) => {
const config = STATUS_CONFIG[status];
return (
<span
onClick={onClick}
className={`inline-flex items-center gap-1 font-semibold rounded-full border transition-all select-none ${
onClick ? 'cursor-pointer hover:brightness-125' : ''
} ${
size === 'sm'
? 'text-[10px] px-2 py-0.5'
: 'text-xs px-2.5 py-1'
}`}
style={{
color: config.color,
backgroundColor: config.bgColor,
borderColor: `${config.color}30`,
}}
title={`Estado: ${config.label}`}
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: config.color }}
/>
{config.label}
</span>
);
};
+309
View File
@@ -0,0 +1,309 @@
/**
* BatchDataPanel — Left panel for batch mode in ProductionForm.
*
* Sections:
* 1. Header with piece count + brand note
* 2. Multi-file background upload (defines N)
* 3. Text data table (columns = editable fields, rows = pieces)
* 4. CSV import button
*/
import React, { useRef, useCallback } from 'react';
import {
FileSpreadsheet, Upload, ImageIcon, AlertTriangle,
Trash2, Film, Video,
} from 'lucide-react';
import type { TemplateField, BatchPieceData, CompanyProfile } from '../../types';
interface BatchDataPanelProps {
pieces: BatchPieceData[];
editableSlots: { field: TemplateField; sceneId: string }[];
brand: CompanyProfile;
templateFormat: 'video' | 'image';
onSetBackgrounds: (files: File[]) => void;
onUpdateField: (index: number, fieldId: string, value: string) => void;
onImportCSV: (file: File) => Promise<{ matched: number; unmatched: number }>;
onRemovePiece: (index: number) => void;
backgroundFiles: File[];
}
/** Get only text-type editable slots (for table columns) */
function getTextSlots(editableSlots: BatchDataPanelProps['editableSlots']) {
return editableSlots.filter(s => s.field.type === 'text');
}
export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
pieces,
editableSlots,
brand,
templateFormat,
onSetBackgrounds,
onUpdateField,
onImportCSV,
onRemovePiece,
backgroundFiles,
}) => {
const bgInputRef = useRef<HTMLInputElement>(null);
const csvInputRef = useRef<HTMLInputElement>(null);
const textSlots = getTextSlots(editableSlots);
const isVideo = templateFormat === 'video';
const N = pieces.length;
// ─── Background upload handler ───
const handleBgUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Merge with existing files
const allFiles = [...backgroundFiles, ...files];
onSetBackgrounds(allFiles);
}
// Reset input so re-uploading same files triggers change
if (bgInputRef.current) bgInputRef.current.value = '';
}, [backgroundFiles, onSetBackgrounds]);
// ─── CSV import handler ───
const handleCSVUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
await onImportCSV(file);
if (csvInputRef.current) csvInputRef.current.value = '';
}, [onImportCSV]);
// ─── Max visible rows before "+N more" ───
const MAX_VISIBLE = 8;
const visiblePieces = pieces.slice(0, MAX_VISIBLE);
const overflowCount = Math.max(0, N - MAX_VISIBLE);
return (
<div className="flex flex-col h-full">
{/* ── Header ── */}
<div className="px-5 py-3 border-b border-neutral-800/30 bg-gradient-to-r from-violet-500/5 to-fuchsia-500/5 shrink-0">
<div className="flex items-center gap-2">
<FileSpreadsheet size={13} className="text-violet-400" />
<h2 className="text-xs font-bold text-white">Datos del lote</h2>
{N > 0 && (
<span className="text-[9px] text-violet-400 bg-violet-500/10 px-2 py-0.5 rounded-full font-bold">
{N} pieza{N !== 1 ? 's' : ''}
</span>
)}
</div>
<p className="text-[10px] text-neutral-500 mt-1">
El estilo de <span className="text-amber-400">{brand.name}</span> se aplica a todas.
</p>
</div>
{/* ── Scrollable content ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-4">
{/* ── Background Upload ── */}
<div className="space-y-1.5">
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300 font-medium">
{isVideo
? <Video size={11} className="text-sky-400" />
: <ImageIcon size={11} className="text-sky-400" />}
{isVideo ? 'Videos de fondo' : 'Imágenes de fondo'}
<span className="text-red-400 text-[10px]">*</span>
</label>
<button
type="button"
onClick={() => bgInputRef.current?.click()}
title={isVideo ? 'Subir videos de fondo (define la cantidad de piezas)' : 'Subir imágenes de fondo (define la cantidad de piezas)'}
className={`w-full flex items-center gap-3 px-4 py-3 border-2 border-dashed rounded-lg transition-all cursor-pointer ${
N > 0
? 'border-violet-500/30 bg-violet-950/10 hover:border-violet-500/50'
: 'border-neutral-700 bg-neutral-800/30 hover:border-neutral-600'
}`}
>
<Upload size={16} className={N > 0 ? 'text-violet-400' : 'text-neutral-600'} />
<div className="text-left flex-1">
{N > 0 ? (
<span className="text-xs text-white font-medium">
{N} {isVideo ? 'video' : 'imagen'}{N !== 1 ? (isVideo ? 's' : 'es') : ''} cargada{N !== 1 ? 's' : ''}
</span>
) : (
<span className="text-xs text-neutral-500">
{isVideo ? 'Subir videos' : 'Subir imágenes'} (selección múltiple)
</span>
)}
</div>
{N > 0 && (
<span className="text-[9px] text-neutral-500">+ agregar</span>
)}
</button>
<input
ref={bgInputRef}
type="file"
accept={isVideo ? 'video/*' : 'image/*'}
multiple
className="hidden"
onChange={handleBgUpload}
/>
<p className="text-[9px] text-neutral-600">
Definen la cantidad de piezas.
</p>
</div>
{/* ── Text Data Table ── */}
{textSlots.length > 0 && N > 0 && (
<div className="space-y-2">
{/* Table header with CSV import */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{textSlots.map(({ field }) => (
<span key={field.id} className="flex items-center gap-1 text-[10px] text-neutral-400">
<span className="text-[11px] font-medium text-neutral-300">{field.label}</span>
{field.required && <span className="text-red-400 text-[8px]">*</span>}
</span>
)).reduce<React.ReactNode[]>((acc, el, i) => {
if (i > 0) acc.push(<span key={`sep-${i}`} className="text-neutral-700 text-[8px]">·</span>);
acc.push(el);
return acc;
}, [])}
</div>
<button
type="button"
onClick={() => csvInputRef.current?.click()}
title="Importar datos desde CSV"
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-[9px] font-semibold text-amber-400 hover:bg-amber-500/10 transition-colors"
>
<FileSpreadsheet size={10} />
Importar CSV
</button>
<input
ref={csvInputRef}
type="file"
accept=".csv,.tsv,.txt"
className="hidden"
onChange={handleCSVUpload}
/>
</div>
{/* Data table */}
<div className="rounded-lg border border-neutral-800/60 overflow-hidden">
{/* Table header row */}
<div
className="grid gap-px bg-neutral-800/50 text-[9px] text-neutral-500 font-bold uppercase tracking-wider"
style={{
gridTemplateColumns: `36px 90px ${textSlots.map(() => '1fr').join(' ')} 28px`,
}}
>
<div className="bg-neutral-900/80 px-2 py-1.5 text-center">#</div>
<div className="bg-neutral-900/80 px-2 py-1.5">Fondo</div>
{textSlots.map(({ field }) => (
<div key={field.id} className="bg-neutral-900/80 px-2 py-1.5 truncate">
{field.label}
</div>
))}
<div className="bg-neutral-900/80 px-1 py-1.5" />
</div>
{/* Data rows */}
{visiblePieces.map((piece) => {
const hasErrors = Object.keys(piece.errors).length > 0;
return (
<div
key={piece.index}
className={`grid gap-px text-xs ${
hasErrors ? 'bg-red-500/5' : 'bg-neutral-800/20'
}`}
style={{
gridTemplateColumns: `36px 90px ${textSlots.map(() => '1fr').join(' ')} 28px`,
}}
>
{/* Row number */}
<div className="bg-neutral-900/60 px-2 py-1.5 text-center text-neutral-500 text-[10px] font-mono flex items-center justify-center">
{piece.index + 1}
</div>
{/* Background filename */}
<div className="bg-neutral-900/60 px-2 py-1.5 flex items-center gap-1 min-w-0">
<span className="text-[9px] text-neutral-400 truncate font-mono">
{piece.backgroundFilename}
</span>
</div>
{/* Text fields */}
{textSlots.map(({ field }) => {
const val = piece.fieldData[field.id] || '';
const err = piece.errors[field.id];
return (
<div key={field.id} className="bg-neutral-900/60 px-0.5 py-0.5 flex items-center">
<input
type="text"
value={val}
onChange={(e) => onUpdateField(piece.index, field.id, e.target.value)}
placeholder={field.content || field.label}
title={err || field.label}
className={`w-full bg-transparent px-1.5 py-1 rounded text-[10px] text-white placeholder-neutral-600 focus:outline-none focus:bg-neutral-800/50 transition-colors ${
err ? 'text-red-400 ring-1 ring-red-500/30' : ''
}`}
style={{ fontFamily: 'inherit' }}
/>
</div>
);
})}
{/* Delete row */}
<div className="bg-neutral-900/60 flex items-center justify-center">
<button
type="button"
onClick={() => onRemovePiece(piece.index)}
title="Quitar pieza"
className="p-0.5 text-neutral-600 hover:text-red-400 transition-colors"
>
<Trash2 size={10} />
</button>
</div>
</div>
);
})}
{/* Overflow indicator */}
{overflowCount > 0 && (
<div className="bg-neutral-900/40 px-3 py-2 text-center text-[10px] text-neutral-500">
+ {overflowCount} fila{overflowCount !== 1 ? 's' : ''}
</div>
)}
</div>
{/* Validation summary */}
{pieces.some(p => !p.isValid) && (
<div className="flex items-center gap-1.5 text-[9px] text-amber-400">
<AlertTriangle size={10} />
<span>
{pieces.filter(p => !p.isValid).length} pieza{pieces.filter(p => !p.isValid).length !== 1 ? 's' : ''} con datos faltantes
</span>
</div>
)}
</div>
)}
{/* ── Empty state (no text fields) ── */}
{textSlots.length === 0 && N > 0 && (
<div className="text-center py-4">
<p className="text-[10px] text-neutral-500">
Esta plantilla no tiene campos de texto editables.
</p>
<p className="text-[9px] text-neutral-600 mt-1">
Solo se varía el fondo en cada pieza.
</p>
</div>
)}
{/* ── Empty state (no backgrounds yet) ── */}
{N === 0 && (
<div className="text-center py-8">
<Upload size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">
Sube {isVideo ? 'videos' : 'imágenes'} de fondo para comenzar
</p>
<p className="text-[10px] text-neutral-600 mt-1">
La cantidad define cuántas piezas se generan.
</p>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,520 @@
/**
* BatchPreviewGrid — Right panel for batch mode in ProductionForm.
*
* For IMAGE templates: shows a grid of thumbnails (each piece rendered).
* For VIDEO templates: shows a single preview player (not N players).
*
* Click on a thumbnail → fullscreen carousel with prev/next navigation.
*/
import React, { useState, useMemo, useCallback, useRef } from 'react';
import {
X, ChevronLeft, ChevronRight, AlertTriangle, Eye,
} from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player';
import { BrandComposition } from '../BrandComposition';
import {
compileExpressToTimeline, getAspectDimensions, getTemplateDuration,
} from '../../utils/expressCompiler';
import type {
BatchPieceData, ExpressTemplate, CompanyProfile, DesignMD,
} from '../../types';
interface BatchPreviewGridProps {
pieces: BatchPieceData[];
template: ExpressTemplate;
brand: CompanyProfile;
designMD: DesignMD;
/** Active piece index for video single-preview mode */
activePieceIndex: number;
onActivePieceChange: (index: number) => void;
}
/**
* Compile a single piece into Remotion inputProps.
* Merges the piece's background URL into fieldData for the background field.
*/
function compilePiece(
piece: BatchPieceData,
template: ExpressTemplate,
designMD: DesignMD,
brand: CompanyProfile,
backgroundFieldId: string | null,
) {
// Build fieldData with the background injected
const fieldData: Record<string, string> = { ...piece.fieldData };
if (backgroundFieldId && piece.backgroundUrl) {
fieldData[backgroundFieldId] = piece.backgroundUrl;
}
const result = compileExpressToTimeline(template, fieldData, designMD, brand);
// Strip transitions for static preview
result.elements = result.elements.map(el => ({
...el,
transitionIn: undefined,
transitionOut: undefined,
}));
return result;
}
/** Find the background field ID (first image/video editable-slot with isBackground) */
function findBackgroundFieldId(template: ExpressTemplate): string | null {
for (const scene of template.scenes) {
const fields = scene.fields ?? [];
// First: look for explicit background field
const bgField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video') && f.isBackground
);
if (bgField) return bgField.id;
// Fallback: first editable media field
const mediaField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video')
);
if (mediaField) return mediaField.id;
}
return null;
}
// ─── Thumbnail component (memoized) ───
const PieceThumbnail: React.FC<{
piece: BatchPieceData;
template: ExpressTemplate;
designMD: DesignMD;
brand: CompanyProfile;
backgroundFieldId: string | null;
dimensions: { w: number; h: number };
totalFrames: number;
onClick: () => void;
isVideo: boolean;
}> = React.memo(({
piece, template, designMD, brand, backgroundFieldId,
dimensions, totalFrames, onClick, isVideo,
}) => {
const compiled = useMemo(
() => compilePiece(piece, template, designMD, brand, backgroundFieldId),
[piece, template, designMD, brand, backgroundFieldId],
);
const inputProps = useMemo(() => ({
designMD,
timelineElements: compiled.elements,
layers: compiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: template.format,
}), [designMD, compiled, template.format]);
const playerKey = useMemo(() =>
compiled.elements
.filter(el => el.type === 'video' || el.type === 'image')
.map(el => el.content || '')
.join('|'),
[compiled],
);
const hasErrors = !piece.isValid || Object.keys(piece.errors).length > 0;
const hasBackground = !!piece.backgroundUrl;
// For text-only label, get first text field value
const firstTextValue = (Object.values(piece.fieldData) as string[]).find(v => v?.trim());
return (
<button
type="button"
onClick={onClick}
title={`Pieza ${piece.index + 1}${hasErrors ? ' — datos faltantes' : ''}`}
className={`relative rounded-lg overflow-hidden border-2 transition-all hover:scale-[1.02] hover:shadow-lg hover:shadow-violet-900/20 cursor-pointer group ${
hasErrors
? 'border-amber-500/40'
: 'border-neutral-800/40 hover:border-violet-500/30'
}`}
style={{ aspectRatio: `${dimensions.w} / ${dimensions.h}` }}
>
{/* Render the piece */}
{!isVideo ? (
<Player
key={playerKey}
component={BrandComposition}
inputProps={inputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={30}
style={{ width: '100%', height: '100%', pointerEvents: 'none' }}
controls={false}
autoPlay={false}
/>
) : (
/* For video, show a static thumbnail from the background */
<div className="w-full h-full bg-neutral-900 flex items-center justify-center">
{hasBackground ? (
<video
src={piece.backgroundUrl}
muted
playsInline
className="w-full h-full object-cover"
onLoadedData={(e) => {
// Seek to 1 second for a useful thumbnail frame
(e.target as HTMLVideoElement).currentTime = 1;
}}
/>
) : (
<div className="text-neutral-700 text-[10px]">Sin fondo</div>
)}
</div>
)}
{/* Error indicator */}
{hasErrors && (
<div className="absolute top-1 right-1 bg-amber-500/90 rounded-full p-0.5">
<AlertTriangle size={10} className="text-black" />
</div>
)}
{/* Text label at bottom */}
{firstTextValue && (
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1.5">
<span className="text-[9px] text-amber-400 font-medium truncate block">
{firstTextValue}
</span>
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-violet-500/0 group-hover:bg-violet-500/10 transition-colors flex items-center justify-center">
<Eye size={16} className="text-white opacity-0 group-hover:opacity-60 transition-opacity" />
</div>
</button>
);
});
PieceThumbnail.displayName = 'PieceThumbnail';
// ─── Overflow "+N" indicator ───
const OverflowThumbnail: React.FC<{
count: number;
dimensions: { w: number; h: number };
onClick: () => void;
}> = ({ count, dimensions, onClick }) => (
<button
type="button"
onClick={onClick}
title={`Ver ${count} piezas más`}
className="relative rounded-lg overflow-hidden border-2 border-neutral-800/40 bg-neutral-900/80 flex items-center justify-center hover:border-violet-500/30 transition-all cursor-pointer"
style={{ aspectRatio: `${dimensions.w} / ${dimensions.h}` }}
>
<span className="text-lg font-bold text-neutral-400">+{count}</span>
</button>
);
// ─── Main component ───
export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
pieces,
template,
brand,
designMD,
activePieceIndex,
onActivePieceChange,
}) => {
const [carouselIndex, setCarouselIndex] = useState<number | null>(null);
const carouselPlayerRef = useRef<PlayerRef>(null);
const dimensions = useMemo(() => getAspectDimensions(template.aspectRatio), [template.aspectRatio]);
const totalDuration = useMemo(() => getTemplateDuration(template), [template]);
const totalFrames = Math.max(30, totalDuration * 30);
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
const isVideo = template.format === 'video';
const N = pieces.length;
// Grid columns based on aspect ratio
const gridCols = template.aspectRatio === '16:9' ? 2
: template.aspectRatio === '1:1' ? 3
: 3; // 9:16, 4:5, etc.
// Max thumbnails to show in grid
const MAX_GRID = 8;
const visiblePieces = pieces.slice(0, MAX_GRID);
const overflowCount = Math.max(0, N - MAX_GRID);
// ─── Carousel ───
const openCarousel = useCallback((index: number) => {
setCarouselIndex(index);
}, []);
const closeCarousel = useCallback(() => {
setCarouselIndex(null);
}, []);
const navigateCarousel = useCallback((delta: number) => {
setCarouselIndex(prev => {
if (prev === null) return null;
const next = prev + delta;
if (next < 0 || next >= N) return prev;
return next;
});
}, [N]);
// Keyboard navigation for carousel
React.useEffect(() => {
if (carouselIndex === null) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeCarousel();
else if (e.key === 'ArrowLeft') navigateCarousel(-1);
else if (e.key === 'ArrowRight') navigateCarousel(1);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [carouselIndex, closeCarousel, navigateCarousel]);
// Compile carousel piece
const carouselPiece = carouselIndex !== null ? pieces[carouselIndex] : null;
const carouselCompiled = useMemo(() => {
if (!carouselPiece) return null;
return compilePiece(carouselPiece, template, designMD, brand, backgroundFieldId);
}, [carouselPiece, template, designMD, brand, backgroundFieldId]);
const carouselInputProps = useMemo(() => {
if (!carouselCompiled) return null;
return {
designMD,
timelineElements: carouselCompiled.elements,
layers: carouselCompiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: template.format,
};
}, [designMD, carouselCompiled, template.format]);
// ─── Video single preview mode ───
const videoPreviewPiece = isVideo && N > 0 ? pieces[activePieceIndex] ?? pieces[0] : null;
const videoCompiled = useMemo(() => {
if (!videoPreviewPiece) return null;
return compilePiece(videoPreviewPiece, template, designMD, brand, backgroundFieldId);
}, [videoPreviewPiece, template, designMD, brand, backgroundFieldId]);
const videoInputProps = useMemo(() => {
if (!videoCompiled) return null;
return {
designMD,
timelineElements: videoCompiled.elements,
layers: videoCompiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: template.format,
};
}, [designMD, videoCompiled, template.format]);
const videoPlayerKey = useMemo(() => {
if (!videoCompiled) return '';
return videoCompiled.elements
.filter(el => el.type === 'video' || el.type === 'image')
.map(el => el.content || '')
.join('|') + `-${activePieceIndex}`;
}, [videoCompiled, activePieceIndex]);
return (
<div className="flex-1 flex flex-col items-center justify-center relative z-10 overflow-hidden">
{/* Header */}
<div className="absolute top-4 left-5 flex items-center gap-2 z-20">
<div className={`w-2 h-2 rounded-full ${N > 0 ? 'bg-emerald-400' : 'bg-neutral-600'}`} />
<span className="text-xs font-semibold text-neutral-300">Preview del lote</span>
{N > 0 && (
<span className="text-[9px] px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400 font-mono">
{N} pieza{N !== 1 ? 's' : ''}
</span>
)}
</div>
{/* Hint */}
{N > 0 && !isVideo && (
<div className="absolute top-4 right-5 z-20">
<span className="text-[9px] text-neutral-600">
grilla · clic = grande
</span>
</div>
)}
{N === 0 ? (
/* Empty state */
<div className="text-center">
<div className="w-16 h-16 rounded-2xl bg-neutral-900 border border-neutral-800/50 flex items-center justify-center mx-auto mb-3">
<Eye size={24} className="text-neutral-700" />
</div>
<p className="text-xs text-neutral-500">Sube fondos para ver el preview</p>
<p className="text-[10px] text-neutral-600 mt-1">
Cada fondo genera una pieza con el diseño de la plantilla
</p>
</div>
) : isVideo ? (
/* ── Video mode: Single preview with piece selector ── */
<div className="flex flex-col items-center gap-3">
{/* Player */}
{videoInputProps && (
<div
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/60 border border-neutral-800/40"
style={{
width: template.aspectRatio === '9:16' ? 240
: template.aspectRatio === '1:1' ? 320
: template.aspectRatio === '4:5' ? 280
: 420,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100% - 120px)',
}}
>
<Player
key={videoPlayerKey}
component={BrandComposition}
inputProps={videoInputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={30}
style={{ width: '100%', height: '100%' }}
controls
autoPlay={false}
loop
/>
</div>
)}
{/* Piece selector */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onActivePieceChange(Math.max(0, activePieceIndex - 1))}
disabled={activePieceIndex <= 0}
title="Pieza anterior"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors disabled:opacity-30"
>
<ChevronLeft size={12} />
</button>
<span className="text-[10px] text-neutral-300 font-mono min-w-[60px] text-center">
Pieza {activePieceIndex + 1} / {N}
</span>
<button
type="button"
onClick={() => onActivePieceChange(Math.min(N - 1, activePieceIndex + 1))}
disabled={activePieceIndex >= N - 1}
title="Pieza siguiente"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors disabled:opacity-30"
>
<ChevronRight size={12} />
</button>
</div>
</div>
) : (
/* ── Image mode: Thumbnail grid ── */
<div
className="grid gap-2 p-4 max-h-[calc(100%-80px)] overflow-y-auto custom-scrollbar"
style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)`, maxWidth: 600 }}
>
{visiblePieces.map((piece) => (
<PieceThumbnail
key={piece.index}
piece={piece}
template={template}
designMD={designMD}
brand={brand}
backgroundFieldId={backgroundFieldId}
dimensions={dimensions}
totalFrames={totalFrames}
onClick={() => openCarousel(piece.index)}
isVideo={false}
/>
))}
{overflowCount > 0 && (
<OverflowThumbnail
count={overflowCount}
dimensions={dimensions}
onClick={() => openCarousel(MAX_GRID)}
/>
)}
</div>
)}
{/* Bottom hint */}
<p className="absolute bottom-4 text-[10px] text-neutral-600 z-10">
{isVideo
? 'Mismo layout y estilo en todas las piezas.'
: N > 0
? `Mismo layout y estilo en las ${N}.`
: 'Se actualiza al cargar fondos y textos'
}
</p>
{/* ═══ Fullscreen Carousel Modal ═══ */}
{carouselIndex !== null && carouselInputProps && (
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center">
{/* Close */}
<button
type="button"
onClick={closeCarousel}
title="Cerrar (Esc)"
className="absolute top-4 right-4 z-50 w-10 h-10 rounded-full bg-neutral-800 hover:bg-neutral-700 text-white flex items-center justify-center transition-colors"
>
<X size={18} />
</button>
{/* Navigation */}
{carouselIndex > 0 && (
<button
type="button"
onClick={() => navigateCarousel(-1)}
title="Anterior (←)"
className="absolute left-4 z-50 w-10 h-10 rounded-full bg-neutral-800/80 hover:bg-neutral-700 text-white flex items-center justify-center transition-colors"
>
<ChevronLeft size={20} />
</button>
)}
{carouselIndex < N - 1 && (
<button
type="button"
onClick={() => navigateCarousel(1)}
title="Siguiente (→)"
className="absolute right-4 z-50 w-10 h-10 rounded-full bg-neutral-800/80 hover:bg-neutral-700 text-white flex items-center justify-center transition-colors"
>
<ChevronRight size={20} />
</button>
)}
{/* Piece counter */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-neutral-800/80 backdrop-blur-sm px-4 py-1.5 rounded-full">
<span className="text-sm text-white font-mono">
{carouselIndex + 1} / {N}
</span>
</div>
{/* Full-size preview */}
<div
className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-700/30"
style={{
width: template.aspectRatio === '9:16' ? 380
: template.aspectRatio === '1:1' ? 500
: template.aspectRatio === '4:5' ? 440
: 640,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100vh - 100px)',
}}
>
<Player
key={`carousel-${carouselIndex}`}
ref={carouselPlayerRef}
component={BrandComposition}
inputProps={carouselInputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={30}
style={{ width: '100%', height: '100%' }}
controls={isVideo}
autoPlay={false}
/>
</div>
</div>
)}
</div>
);
};
+251
View File
@@ -0,0 +1,251 @@
import React, { useState, useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import {
FolderOpen, Search, Plus, Palette, CalendarDays,
GripVertical, Briefcase, Copy, Trash2,
} from 'lucide-react';
import { CompanyProfile } from '../../types';
interface BrandsPanelProps {
companies: CompanyProfile[];
onSelect: (company: CompanyProfile) => void;
onCreateBrand: () => void;
onEditBrand: (company: CompanyProfile) => void;
onDeleteBrand: (id: string) => void;
onDuplicateBrand: (id: string) => void;
onOpenContentGrid: (companyId: string) => void;
}
/**
* BrandsPanel — Top-right panel showing a searchable, draggable grid of brand folders.
*/
export const BrandsPanel: React.FC<BrandsPanelProps> = ({
companies,
onSelect,
onCreateBrand,
onEditBrand,
onDeleteBrand,
onDuplicateBrand,
onOpenContentGrid,
}) => {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search.trim()) return companies;
const q = search.toLowerCase();
return companies.filter(c =>
c.name.toLowerCase().includes(q) ||
(c.industry || '').toLowerCase().includes(q)
);
}, [companies, search]);
return (
<div className="flex-1 min-w-0 bg-neutral-900/50 border border-neutral-800/50 rounded-2xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="text-amber-400" />
<h2 className="text-sm font-bold text-white">Marcas</h2>
<span className="text-[10px] bg-neutral-800 text-neutral-400 px-1.5 py-0.5 rounded-full font-mono">
{companies.length}
</span>
</div>
<button
onClick={onCreateBrand}
title="Crear nueva marca"
className="flex items-center gap-1 text-[10px] text-amber-400 hover:text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 hover:border-amber-500/40 px-2 py-1 rounded-lg font-semibold transition-all"
>
<Plus size={11} /> Nueva
</button>
</div>
{/* Search */}
<div className="px-4 pb-3">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar marca..."
className="w-full bg-neutral-800/60 border border-neutral-700/50 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-amber-500/40 transition-colors"
/>
</div>
</div>
{/* Grid */}
<div className="flex-1 overflow-y-auto px-4 pb-4 custom-scrollbar">
<div className="grid grid-cols-2 gap-2">
{filtered.map(company => (
<DraggableBrand
key={company.id}
company={company}
onSelect={onSelect}
onEditBrand={onEditBrand}
onDeleteBrand={onDeleteBrand}
onDuplicateBrand={onDuplicateBrand}
onOpenContentGrid={onOpenContentGrid}
/>
))}
</div>
{companies.length === 0 && (
<div className="text-center py-8 text-neutral-600">
<Briefcase size={24} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">No hay marcas creadas</p>
<p className="text-[10px] mt-1 text-neutral-700">Haz clic en "Nueva" para empezar</p>
</div>
)}
{filtered.length === 0 && search.trim() && companies.length > 0 && (
<div className="text-center py-6 text-neutral-600">
<Search size={20} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">Sin resultados para "{search}"</p>
</div>
)}
</div>
</div>
);
};
/* ── Draggable brand folder ── */
const DraggableBrand: React.FC<{
company: CompanyProfile;
onSelect: (c: CompanyProfile) => void;
onEditBrand: (c: CompanyProfile) => void;
onDeleteBrand: (id: string) => void;
onDuplicateBrand: (id: string) => void;
onOpenContentGrid: (id: string) => void;
}> = ({ company, onSelect, onEditBrand, onDeleteBrand, onDuplicateBrand, onOpenContentGrid }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: `brand-${company.id}`,
data: { type: 'brand', company },
});
const style = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onClick={() => onSelect(company)}
title={`${company.name}${company.industry ? ` · ${company.industry}` : ''}`}
className={`
group relative rounded-xl border p-3 cursor-grab active:cursor-grabbing
transition-all duration-150
${isDragging
? 'border-amber-500/60 shadow-xl shadow-amber-900/30 z-50 bg-neutral-900'
: 'border-neutral-800/60 bg-neutral-950/30 hover:border-amber-500/30 hover:bg-neutral-900/60'
}
`}
>
{/* Drag grip hint */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-60 transition-opacity">
<GripVertical size={10} className="text-neutral-500" />
</div>
{/* Brand icon (folder) */}
<div className="flex items-center gap-2.5 mb-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center p-1 border shrink-0"
style={{
backgroundColor: company.design.secondaryColor,
borderColor: `${company.design.primaryColor}40`,
}}
>
{company.design.logoUrl ? (
<img src={company.design.logoUrl} className="max-w-full max-h-full object-contain" alt="" />
) : (
<FolderOpen size={14} className="text-amber-400" />
)}
</div>
<p className="text-xs font-bold text-white truncate group-hover:text-amber-300 transition-colors">
{company.name}
</p>
</div>
{/* Color dots */}
<div className="flex items-center gap-3">
<div className="flex gap-1">
<div className="w-3 h-3 rounded-full border border-neutral-700" style={{ backgroundColor: company.design.primaryColor }} title="Primario" />
<div className="w-3 h-3 rounded-full border border-neutral-700" style={{ backgroundColor: company.design.secondaryColor }} title="Secundario" />
<div className="w-3 h-3 rounded-full border border-neutral-700" style={{ backgroundColor: company.design.textColor }} title="Texto" />
</div>
</div>
{/* Hover actions */}
<div className="absolute bottom-1.5 right-1.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); onEditBrand(company); }}
title="Editar Design Kit"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Palette size={9} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onOpenContentGrid(company.id); }}
title="Malla de Contenidos"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-fuchsia-400 rounded transition-colors"
>
<CalendarDays size={9} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDuplicateBrand(company.id); }}
title="Duplicar marca"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Copy size={9} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeleteBrand(company.id); }}
title="Eliminar marca"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-red-400 rounded transition-colors"
>
<Trash2 size={9} />
</button>
</div>
</div>
);
};
/**
* DragOverlay content for a brand being dragged.
*/
export const BrandDragPreview: React.FC<{ company: CompanyProfile }> = ({ company }) => (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-neutral-800/95 border border-amber-500/50 shadow-2xl shadow-amber-900/40 backdrop-blur-sm">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center p-1 border"
style={{
backgroundColor: company.design.secondaryColor,
borderColor: `${company.design.primaryColor}40`,
}}
>
{company.design.logoUrl ? (
<img src={company.design.logoUrl} className="max-w-full max-h-full object-contain" alt="" />
) : (
<FolderOpen size={14} className="text-amber-400" />
)}
</div>
<div>
<p className="text-xs font-bold text-white">{company.name}</p>
<div className="flex gap-1 mt-0.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: company.design.primaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: company.design.secondaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: company.design.textColor }} />
</div>
</div>
</div>
);
+150
View File
@@ -0,0 +1,150 @@
import React from 'react';
import { useDroppable } from '@dnd-kit/core';
import {
Layers, FolderOpen, X,
Video, Image as ImageIcon,
} from 'lucide-react';
import { ExpressTemplate, CompanyProfile } from '../../types';
interface DropSlotProps {
type: 'template' | 'brand';
item: ExpressTemplate | CompanyProfile | null;
onClear: () => void;
onClick: () => void;
}
/**
* DropSlot — A single droppable slot for either a template or brand.
*
* States:
* - Empty: dashed border, placeholder icon + text
* - Drag hover: highlighted border, glowing background
* - Filled: shows selected item with remove button
*/
export const DropSlot: React.FC<DropSlotProps> = ({
type,
item,
onClear,
onClick,
}) => {
const { isOver, setNodeRef } = useDroppable({
id: `slot-${type}`,
data: { accepts: type },
});
const isEmpty = !item;
const label = type === 'template' ? 'Plantilla' : 'Marca';
const hint = type === 'template' ? 'Suelta una plantilla' : 'Suelta una marca';
return (
<div
ref={setNodeRef}
onClick={isEmpty ? onClick : undefined}
className={`
relative flex items-center gap-3 px-4 py-4 rounded-xl border-2 transition-all duration-200 min-w-[180px] cursor-pointer
${isEmpty && !isOver
? 'border-dashed border-neutral-700/60 bg-neutral-900/30 hover:border-neutral-600 hover:bg-neutral-900/50'
: ''
}
${isEmpty && isOver
? 'border-dashed border-violet-500/70 bg-violet-950/30 scale-[1.02] shadow-lg shadow-violet-900/20'
: ''
}
${!isEmpty
? 'border-solid border-violet-500/40 bg-neutral-900/60'
: ''
}
`}
title={isEmpty ? `Haz clic o arrastra para elegir ${label.toLowerCase()}` : `${label} seleccionada`}
>
{isEmpty ? (
/* ── Empty state ── */
<div className="flex items-center gap-3 py-1">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
isOver ? 'bg-violet-600/20 text-violet-400' : 'bg-neutral-800/60 text-neutral-600'
}`}>
{type === 'template' ? <Layers size={20} /> : <FolderOpen size={20} />}
</div>
<div>
<p className={`text-[10px] font-semibold uppercase tracking-wider transition-colors ${
isOver ? 'text-violet-400' : 'text-neutral-500'
}`}>
{label}
</p>
<p className={`text-xs transition-colors ${
isOver ? 'text-violet-300/70' : 'text-neutral-600'
}`}>
{hint}
</p>
</div>
</div>
) : type === 'template' ? (
/* ── Filled: Template ── */
<FilledTemplate template={item as ExpressTemplate} />
) : (
/* ── Filled: Brand ── */
<FilledBrand brand={item as CompanyProfile} />
)}
{/* Clear button */}
{!isEmpty && (
<button
onClick={(e) => { e.stopPropagation(); onClear(); }}
title={`Quitar ${label.toLowerCase()}`}
className="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-neutral-800 border border-neutral-700 flex items-center justify-center text-neutral-400 hover:text-red-400 hover:border-red-500/40 transition-colors shadow-sm"
>
<X size={10} />
</button>
)}
</div>
);
};
/* ── Sub-components for filled state ── */
const FilledTemplate: React.FC<{ template: ExpressTemplate }> = ({ template }) => (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-violet-600/15 flex items-center justify-center text-lg">
{template.icon}
</div>
<div>
<p className="text-[10px] text-violet-400 font-semibold uppercase tracking-wider">Plantilla</p>
<p className="text-xs font-bold text-white">{template.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
{template.format === 'video' ? (
<Video size={9} className="text-violet-400" />
) : (
<ImageIcon size={9} className="text-sky-400" />
)}
<span className="text-[8px] text-neutral-500 font-mono">{template.aspectRatio}</span>
</div>
</div>
</div>
);
const FilledBrand: React.FC<{ brand: CompanyProfile }> = ({ brand }) => (
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center p-1.5 border"
style={{
backgroundColor: brand.design.secondaryColor,
borderColor: `${brand.design.primaryColor}40`,
}}
>
{brand.design.logoUrl ? (
<img src={brand.design.logoUrl} className="max-w-full max-h-full object-contain" alt="Logo" />
) : (
<FolderOpen size={16} className="text-neutral-400" />
)}
</div>
<div>
<p className="text-[10px] text-amber-400 font-semibold uppercase tracking-wider">Marca</p>
<p className="text-xs font-bold text-white">{brand.name}</p>
<div className="flex gap-1 mt-0.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: brand.design.primaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: brand.design.secondaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: brand.design.textColor }} />
</div>
</div>
</div>
);
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import { Sparkles, ArrowRight } from 'lucide-react';
import { ExpressTemplate, CompanyProfile } from '../../types';
import { DropSlot } from './DropSlot';
interface GenerateZoneProps {
selectedTemplate: ExpressTemplate | null;
selectedBrand: CompanyProfile | null;
onClearTemplate: () => void;
onClearBrand: () => void;
onClickTemplateSlot: () => void;
onClickBrandSlot: () => void;
onGenerate: () => void;
}
/**
* GenerateZone — Bottom full-width area with two drop slots (Template × Brand) and a Generate button.
*/
export const GenerateZone: React.FC<GenerateZoneProps> = ({
selectedTemplate,
selectedBrand,
onClearTemplate,
onClearBrand,
onClickTemplateSlot,
onClickBrandSlot,
onGenerate,
}) => {
const canGenerate = !!selectedTemplate && !!selectedBrand;
return (
<div className="bg-neutral-900/50 border border-neutral-800/50 rounded-2xl p-5">
{/* Header */}
<div className="flex items-center gap-2 mb-1">
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-violet-600/20 to-fuchsia-600/20 flex items-center justify-center">
<Sparkles size={14} className="text-violet-400" />
</div>
<h2 className="text-sm font-bold text-white">Generar contenido</h2>
</div>
<p className="text-[11px] text-neutral-500 mb-5 ml-8">
Arrastra una plantilla y una marca, o toca para elegir.
</p>
{/* Slots row */}
<div className="flex items-center gap-3">
{/* Template slot */}
<DropSlot
type="template"
item={selectedTemplate}
onClear={onClearTemplate}
onClick={onClickTemplateSlot}
/>
{/* × separator */}
<div className="shrink-0 flex items-center justify-center">
<span className="text-xl font-bold text-neutral-600 select-none">×</span>
</div>
{/* Brand slot */}
<DropSlot
type="brand"
item={selectedBrand}
onClear={onClearBrand}
onClick={onClickBrandSlot}
/>
{/* Generate button */}
<button
onClick={onGenerate}
disabled={!canGenerate}
title={canGenerate ? 'Generar contenido con esta plantilla y marca' : 'Selecciona una plantilla y una marca primero'}
className={`
shrink-0 flex items-center gap-2 px-6 py-4 rounded-xl font-bold text-sm transition-all duration-200
${canGenerate
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-lg shadow-violet-900/30 hover:shadow-violet-900/50 hover:scale-[1.02] active:scale-[0.98]'
: 'bg-neutral-800/50 text-neutral-600 cursor-not-allowed border border-neutral-800'
}
`}
>
Generar
<ArrowRight size={16} />
</button>
</div>
</div>
);
};
+658
View File
@@ -0,0 +1,658 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import {
ArrowLeft, Sparkles, Zap, Play, ChevronRight, ChevronLeft, FileText, Download, Film,
Layers, Package,
} from 'lucide-react';
import { PlayerRef } from '@remotion/player';
import {
ExpressTemplate, CompanyProfile, DesignMD,
TemplateField, BrandSource,
} from '../../types';
import { ExportModal } from '../export/ExportModal';
import { compileExpressToTimeline, getTemplateDuration } from '../../utils/expressCompiler';
import { TemplateFieldInput } from '../shared/TemplateFieldInput';
import { LivePreviewCanvas } from '../shared/LivePreviewCanvas';
import { migrateExpressFields } from '../../context/TemplateBuilderContext';
import { useBatchProduction } from '../../hooks/useBatchProduction';
import { BatchDataPanel } from './BatchDataPanel';
import { exportBatchAsZip, BatchExportProgress } from '../../utils/batchExporter';
interface ProductionFormProps {
template: ExpressTemplate;
brand: CompanyProfile;
onBack: () => void;
onProducePro: (fieldData: Record<string, string>) => void;
}
/** Resolve a brand variable to its value from DesignMD / CompanyProfile */
function resolveBrandValue(source: BrandSource | undefined, brand: CompanyProfile): string {
if (!source) return '';
switch (source) {
case 'brand-name': return brand.name || brand.design.brandName || '';
case 'tagline': return brand.tagline || '';
case 'logo': return brand.design.logoUrl || '';
case 'intro-video': return brand.design.introVideoUrl || '';
case 'outro-video': return brand.design.outroVideoUrl || '';
case 'primary-color': return brand.design.primaryColor;
case 'secondary-color': return brand.design.secondaryColor;
case 'instagram': return brand.socialLinks?.instagram || '';
case 'tiktok': return brand.socialLinks?.tiktok || '';
case 'twitter': return brand.socialLinks?.x || '';
case 'youtube': return brand.socialLinks?.youtube || '';
case 'website': return brand.socialLinks?.website || '';
default: return '';
}
}
/** Get all TemplateFields from a scene, migrating legacy fields if needed */
function getSceneTemplateFields(scene: ExpressTemplate['scenes'][0]): TemplateField[] {
if (scene.fields && scene.fields.length > 0) return scene.fields;
return migrateExpressFields(scene.editableFields);
}
/** Find the background field ID (first image/video editable-slot, prefer isBackground) */
function findBackgroundFieldId(template: ExpressTemplate): string | null {
for (const scene of template.scenes) {
const fields = scene.fields ?? [];
const bgField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video') && f.isBackground
);
if (bgField) return bgField.id;
const mediaField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video')
);
if (mediaField) return mediaField.id;
}
return null;
}
/**
* ProductionForm — Two-panel production screen.
*
* Supports two modes:
* - Single piece (default): editable fields form + live preview
* - Batch mode: multi-file upload + text table + thumbnail grid preview
*
* Uses shared TemplateFieldInput and LivePreviewCanvas components.
*/
export const ProductionForm: React.FC<ProductionFormProps> = ({
template,
brand,
onBack,
onProducePro,
}) => {
const [fieldData, setFieldData] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [activeSceneId, setActiveSceneId] = useState<string | null>(
template.scenes[0]?.id || null
);
const [showExportModal, setShowExportModal] = useState(false);
const [mediaFits, setMediaFits] = useState<Record<string, 'cover' | 'contain' | 'fill'>>({});
const [containBgColors, setContainBgColors] = useState<Record<string, string | null>>({});
// Batch mode state
const [batchExportProgress, setBatchExportProgress] = useState<BatchExportProgress | null>(null);
const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0);
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
const playerRef = useRef<PlayerRef>(null);
const designMD = brand.design;
const fps = 30;
const totalDuration = getTemplateDuration(template);
const totalFrames = Math.max(30, totalDuration * fps);
// ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ───
const compiled = useMemo(
() => {
if (!showExportModal) return { elements: [], layers: [] };
const result = compileExpressToTimeline(template, fieldData, designMD, brand);
result.elements = result.elements.map(el => {
const fieldId = el.sourceFieldId;
const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
const bgOverride = fieldId ? containBgColors[fieldId] : undefined;
return {
...el,
transitionIn: undefined,
transitionOut: undefined,
...(fitOverride ? { objectFit: fitOverride } : {}),
...(bgOverride !== undefined ? { containBgColor: bgOverride } : {}),
};
});
return result;
},
[showExportModal, template, fieldData, designMD, brand, mediaFits, containBgColors]
);
// ─── Collect all TemplateFields across all scenes ───
const allFields = useMemo(() => {
const fields: { field: TemplateField; sceneId: string; sceneName: string }[] = [];
for (const scene of template.scenes) {
const sceneFields = getSceneTemplateFields(scene);
for (const f of sceneFields) {
fields.push({ field: f, sceneId: scene.id, sceneName: scene.name });
}
}
return fields;
}, [template]);
// Separate into editable slots and brand variables
const editableSlots = useMemo(() =>
allFields
.filter(f => f.field.nature === 'editable-slot')
.sort((a, b) => a.field.formOrder - b.field.formOrder),
[allFields]);
const brandVars = useMemo(() =>
allFields.filter(f => f.field.nature === 'brand-variable'),
[allFields]);
// Group editable slots by scene
const sceneGroups = useMemo(() => {
const groups: { sceneId: string; sceneName: string; fields: typeof editableSlots }[] = [];
const seen = new Set<string>();
for (const slot of editableSlots) {
if (!seen.has(slot.sceneId)) {
seen.add(slot.sceneId);
groups.push({
sceneId: slot.sceneId,
sceneName: slot.sceneName,
fields: editableSlots.filter(s => s.sceneId === slot.sceneId),
});
}
}
return groups;
}, [editableSlots]);
const isMultiScene = sceneGroups.length > 1;
// ─── Batch mode hook ───
const batch = useBatchProduction(editableSlots, template.format);
// Active preview fieldData: in batch mode, use the active piece's data with background injected
const activePreviewFieldData = useMemo(() => {
if (!batch.isBatchMode) return fieldData;
const piece = batch.pieces[activeBatchPieceIndex];
if (!piece) return {};
const fd: Record<string, string> = { ...piece.fieldData };
if (backgroundFieldId && piece.backgroundUrl) {
fd[backgroundFieldId] = piece.backgroundUrl;
}
return fd;
}, [batch.isBatchMode, batch.pieces, activeBatchPieceIndex, backgroundFieldId, fieldData]);
const handleChange = useCallback((fieldId: string, value: string) => {
setFieldData(prev => ({ ...prev, [fieldId]: value }));
setErrors(prev => { const next = { ...prev }; delete next[fieldId]; return next; });
}, []);
const validate = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
for (const { field } of editableSlots) {
const value = fieldData[field.id]?.trim();
if (field.required && !value) {
newErrors[field.id] = 'Campo obligatorio';
}
if (field.type === 'text' && field.rules?.maxChars && value && value.length > field.rules.maxChars) {
newErrors[field.id] = `Máximo ${field.rules.maxChars} caracteres`;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [editableSlots, fieldData]);
// ─── Detect form-sourced segments (intro/outro that need video upload) ───
const formSegments = useMemo(() =>
template.scenes.filter(
s => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'form'
),
[template]);
// ─── Detect brand-sourced segments (auto intro/outro from brand) ───
const brandSegments = useMemo(() =>
template.scenes.filter(
s => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'brand'
),
[template]);
// ─── Required fields completion check (includes segments) ───
const requiredComplete = useMemo(() => {
const slotsComplete = editableSlots
.filter(s => s.field.required)
.every(s => !!fieldData[s.field.id]?.trim());
const segmentsComplete = formSegments
.filter(s => s.segmentFieldRequired !== false)
.every(s => !!fieldData[`segment-${s.id}`]?.trim());
return slotsComplete && segmentsComplete;
}, [editableSlots, fieldData, formSegments]);
const handleProducePro = () => { if (validate()) onProducePro(fieldData); };
const handleProduce = () => { if (validate()) setShowExportModal(true); };
// ─── Batch export handler ───
const handleBatchExport = useCallback(async () => {
if (!batch.validateAll()) return;
setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' });
try {
await exportBatchAsZip(
batch.pieces,
template,
brand,
{ format: 'png' },
(progress) => setBatchExportProgress(progress),
);
} catch (err) {
console.error('Batch export failed:', err);
setBatchExportProgress({ current: 0, total: 0, status: 'error', error: String(err) });
}
}, [batch, template, brand]);
return (
<div className="flex-1 flex overflow-hidden bg-neutral-950 relative">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
/>
{/* ═══ LEFT PANEL ═══ */}
<div className="w-[440px] shrink-0 flex flex-col border-r border-neutral-800/60 relative z-10 bg-neutral-950/95 backdrop-blur-sm">
{/* Top bar */}
<div className="px-5 py-3 border-b border-neutral-800/50 shrink-0">
<button
onClick={onBack}
title="Volver al dashboard"
className="flex items-center gap-1.5 text-neutral-400 hover:text-white transition-colors text-xs mb-3"
>
<ArrowLeft size={14} />
Dashboard
</button>
<div className="flex items-center gap-2 mb-1.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-md shadow-violet-900/30">
<Sparkles size={16} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-white tracking-tight truncate">Producir Contenido</h1>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-violet-400 font-semibold truncate">{template.icon} {template.name}</span>
<span className="text-neutral-600 text-[10px]">×</span>
<span className="text-[10px] text-amber-400 font-semibold truncate">{brand.name}</span>
</div>
</div>
{/* ── Batch Toggle ── */}
<button
type="button"
onClick={batch.toggleBatchMode}
title={batch.isBatchMode ? 'Cambiar a pieza única' : 'Cambiar a modo lote'}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-[10px] font-semibold transition-all shrink-0 ${
batch.isBatchMode
? 'bg-violet-600/20 border-violet-500/40 text-violet-300 shadow-sm shadow-violet-900/20'
: 'bg-neutral-900 border-neutral-700 text-neutral-400 hover:text-white hover:border-neutral-600'
}`}
>
<div className={`w-7 h-4 rounded-full relative transition-colors ${
batch.isBatchMode ? 'bg-violet-600' : 'bg-neutral-700'
}`}>
<div className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow-sm transition-all ${
batch.isBatchMode ? 'left-3.5' : 'left-0.5'
}`} />
</div>
<Layers size={11} />
En lote
</button>
</div>
<div className="flex items-center gap-2">
<span className={`text-[9px] px-2 py-0.5 rounded-full font-bold ${
template.format === 'video' ? 'bg-violet-500/15 text-violet-300' : 'bg-sky-500/15 text-sky-300'
}`}>
{template.format === 'video' ? '🎬 Video' : '🖼️ Imagen'} · {template.aspectRatio}
</span>
<span className="text-[9px] px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400 font-mono">
{template.scenes.length} escena{template.scenes.length !== 1 ? 's' : ''}
</span>
</div>
</div>
{/* ═══ CONDITIONAL CONTENT: Single vs Batch ═══ */}
{batch.isBatchMode ? (
/* ── BATCH MODE: BatchDataPanel ── */
<BatchDataPanel
pieces={batch.pieces}
editableSlots={editableSlots}
brand={brand}
templateFormat={template.format}
onSetBackgrounds={batch.setBackgroundFiles}
onUpdateField={batch.updatePieceField}
onImportCSV={batch.importCSV}
onRemovePiece={batch.removePiece}
backgroundFiles={batch.backgroundFiles}
/>
) : (
/* ── SINGLE MODE: Original form ── */
<>
{/* Form header */}
<div className="px-5 py-3 border-b border-neutral-800/30 bg-gradient-to-r from-violet-500/5 to-fuchsia-500/5 shrink-0">
<div className="flex items-center gap-2">
<FileText size={13} className="text-violet-400" />
<h2 className="text-xs font-bold text-white">Campos editables</h2>
<span className="text-[9px] text-violet-400 bg-violet-500/10 px-2 py-0.5 rounded-full font-medium">
{editableSlots.length} campo{editableSlots.length !== 1 ? 's' : ''}
</span>
</div>
<p className="text-[10px] text-neutral-500 mt-1">
El estilo de <span className="text-amber-400">{brand.name}</span> se aplica automáticamente.
</p>
</div>
{/* Scrollable fields */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
{/* ── Segment upload fields (form-sourced intro/outro) ── */}
{formSegments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 px-1">
<Film size={12} className="text-emerald-400" />
<span className="text-[10px] font-bold text-white uppercase tracking-wider">Segmentos de video</span>
</div>
{formSegments.map(scene => {
const segFieldId = `segment-${scene.id}`;
const isIntro = scene.type === 'intro';
const syntheticField: TemplateField = {
id: segFieldId,
nature: 'editable-slot',
type: 'video',
label: scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre'),
required: scene.segmentFieldRequired ?? true,
content: isIntro ? 'Video de intro' : 'Video de cierre',
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: isIntro ? -2 : 999,
};
return (
<TemplateFieldInput
key={segFieldId}
field={syntheticField}
value={fieldData[segFieldId] || ''}
onChange={(v) => handleChange(segFieldId, v)}
error={errors[segFieldId]}
designMD={designMD}
mediaFit={mediaFits[segFieldId]}
onMediaFitChange={(fit) => setMediaFits(prev => ({ ...prev, [segFieldId]: fit }))}
containBgColor={containBgColors[segFieldId] ?? null}
onContainBgColorChange={(color) => setContainBgColors(prev => ({ ...prev, [segFieldId]: color }))}
/>
);
})}
</div>
)}
{editableSlots.length === 0 ? (
<div className="text-center py-8">
<FileText size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">Esta plantilla no tiene campos editables.</p>
<p className="text-[10px] text-neutral-600 mt-1">Todo se genera automáticamente desde la marca.</p>
</div>
) : isMultiScene ? (
/* ── Grouped by scene ── */
sceneGroups.map(group => (
<div key={group.sceneId} className="space-y-3">
<button
onClick={() => setActiveSceneId(group.sceneId)}
title={`Ir a escena: ${group.sceneName}`}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-all ${
activeSceneId === group.sceneId
? 'border-violet-500/30 bg-violet-500/5'
: 'border-neutral-800/50 bg-neutral-900/30 hover:border-neutral-700'
}`}
>
<div className={`w-2 h-2 rounded-full shrink-0 ${
activeSceneId === group.sceneId ? 'bg-violet-500' : 'bg-neutral-600'
}`} />
<span className="text-[11px] font-semibold text-white flex-1">{group.sceneName}</span>
<span className="text-[9px] text-neutral-500">{group.fields.length} campo{group.fields.length !== 1 ? 's' : ''}</span>
</button>
{group.fields.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={fieldData[field.id] || ''}
onChange={(v) => handleChange(field.id, v)}
error={errors[field.id]}
designMD={designMD}
mediaFit={mediaFits[field.id]}
onMediaFitChange={(fit) => setMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={containBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))}
</div>
))
) : (
/* ── Single scene — flat list ── */
editableSlots.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={fieldData[field.id] || ''}
onChange={(v) => handleChange(field.id, v)}
error={errors[field.id]}
designMD={designMD}
mediaFit={mediaFits[field.id]}
onMediaFitChange={(fit) => setMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={containBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))
)}
{/* Brand-sourced segments (auto intro/outro) */}
{brandSegments.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-emerald-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Sparkles size={8} /> Segmentos automáticos
</p>
<div className="space-y-2">
{brandSegments.map(scene => (
<div
key={scene.id}
className="flex items-center gap-3 px-3 py-2.5 bg-emerald-500/5 border border-emerald-500/15 rounded-lg"
>
<Film size={10} className="text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-emerald-300 font-medium">{scene.name}</span>
<span className="text-[9px] text-emerald-400/50 block">
{scene.durationSeconds}s desde la marca
</span>
</div>
<span className="text-[7px] text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
{/* Brand variables (read-only info) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde {brand.name}
</p>
<div className="space-y-2">
{brandVars.map(({ field }) => {
const resolvedValue = resolveBrandValue(field.brandSource, brand);
return (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{field.brandSource === 'logo' ? '(Logo de marca)' : resolvedValue || '(no configurado)'}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
);
})}
</div>
</div>
)}
</div>
</>
)}
{/* ── Sticky footer with actions ── */}
<div className="px-5 py-3 border-t border-neutral-800/60 bg-neutral-950/95 backdrop-blur-sm shrink-0">
<div className="flex items-center gap-2">
<button
onClick={onBack}
title="Cancelar y volver al dashboard"
className="px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-white hover:border-neutral-700 text-[10px] font-medium transition-all"
>
Cancelar
</button>
<div className="flex-1" />
{batch.isBatchMode ? (
/* ── Batch footer ── */
<>
{/* Export progress indicator */}
{batchExportProgress && batchExportProgress.status === 'rendering' && (
<div className="flex items-center gap-2 mr-2">
<div className="w-20 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-violet-600 to-fuchsia-600 rounded-full transition-all"
style={{ width: `${(batchExportProgress.current / batchExportProgress.total) * 100}%` }}
/>
</div>
<span className="text-[9px] text-neutral-400 font-mono">
{batchExportProgress.current}/{batchExportProgress.total}
</span>
</div>
)}
<button
onClick={handleBatchExport}
disabled={batch.pieceCount === 0 || (batchExportProgress?.status === 'rendering')}
title={batch.pieceCount === 0 ? 'Sube fondos para comenzar' : `Generar y descargar ${batch.pieceCount} piezas como ZIP`}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-[10px] font-bold transition-all shadow-lg hover:scale-[1.01] active:scale-[0.99] ${
batch.pieceCount > 0 && batchExportProgress?.status !== 'rendering'
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-violet-900/30 hover:shadow-violet-900/50'
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed shadow-none'
}`}
>
<Package size={12} />
Descargar ZIP ({batch.pieceCount})
<ChevronRight size={10} />
</button>
</>
) : (
/* ── Single piece footer ── */
<>
<button
onClick={handleProducePro}
title="Abrir en Editor Pro con timeline completo"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-white text-[10px] font-semibold transition-all hover:scale-[1.01] active:scale-[0.99]"
>
<Play size={11} />
Editor Pro
<ChevronRight size={10} />
</button>
<button
onClick={handleProduce}
disabled={!requiredComplete}
title={requiredComplete ? 'Producir contenido' : 'Completa los campos obligatorios'}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-[10px] font-bold transition-all shadow-lg hover:scale-[1.01] active:scale-[0.99] ${
requiredComplete
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-violet-900/30 hover:shadow-violet-900/50'
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed shadow-none'
}`}
>
<Download size={12} />
Producir
<ChevronRight size={10} />
</button>
</>
)}
</div>
</div>
</div>
{/* ═══ RIGHT PANEL — Same LivePreviewCanvas for both modes ═══ */}
<div className="flex-1 flex flex-col relative z-10">
<LivePreviewCanvas
template={template}
fieldData={activePreviewFieldData}
brand={brand}
designMD={designMD}
mediaFits={mediaFits}
containBgColors={containBgColors}
activeSceneId={activeSceneId}
onSceneChange={setActiveSceneId}
playerRef={playerRef}
statusLabel={
batch.isBatchMode
? (batch.pieceCount > 0 ? `Pieza ${activeBatchPieceIndex + 1} de ${batch.pieceCount}` : 'Sin piezas')
: (requiredComplete ? 'Listo' : 'Faltan campos')
}
isComplete={
batch.isBatchMode
? (batch.pieceCount > 0 && (batch.pieces[activeBatchPieceIndex]?.isValid ?? false))
: requiredComplete
}
/>
{/* ── Piece Navigator (batch mode only) ── */}
{batch.isBatchMode && batch.pieceCount > 0 && (
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-neutral-900/90 backdrop-blur-sm border border-neutral-700/50 rounded-full px-4 py-2 shadow-xl">
<button
type="button"
onClick={() => setActiveBatchPieceIndex(Math.max(0, activeBatchPieceIndex - 1))}
disabled={activeBatchPieceIndex <= 0}
title="Pieza anterior"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft size={14} />
</button>
<span className="text-[11px] text-neutral-300 font-semibold min-w-[80px] text-center">
Pieza {activeBatchPieceIndex + 1} / {batch.pieceCount}
</span>
<button
type="button"
onClick={() => setActiveBatchPieceIndex(Math.min(batch.pieceCount - 1, activeBatchPieceIndex + 1))}
disabled={activeBatchPieceIndex >= batch.pieceCount - 1}
title="Pieza siguiente"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight size={14} />
</button>
</div>
)}
</div>
{/* ═══ Export Modal (single piece only) ═══ */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
designMD={designMD}
textOverlay=""
timelineElements={compiled.elements}
layers={compiled.layers}
durationInFrames={totalFrames}
brandVisibility={{ logo: false, frame: false, background: true }}
outputFormat={template.format}
aspectRatio={template.aspectRatio}
/>
</div>
);
};
+509
View File
@@ -0,0 +1,509 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import {
Layers, Search, Video, Image as ImageIcon, Plus, ArrowRight,
GripVertical, Pencil, Copy, Trash2, Smartphone, Monitor, Square,
Star, Package, ChevronDown, ChevronRight,
} from 'lucide-react';
import { ExpressTemplate } from '../../types';
const ASPECTS: { value: ExpressTemplate['aspectRatio']; label: string; icon: React.ReactNode; desc: string }[] = [
{ value: '9:16', label: '9:16', icon: <Smartphone size={14} />, desc: 'Stories · Reels' },
{ value: '16:9', label: '16:9', icon: <Monitor size={14} />, desc: 'YouTube · Web' },
{ value: '1:1', label: '1:1', icon: <Square size={14} />, desc: 'Feed · Posts' },
{ value: '4:5', label: '4:5', icon: <Smartphone size={14} />, desc: 'IG vertical' },
];
interface TemplatesPanelProps {
templates: ExpressTemplate[];
onSelect: (template: ExpressTemplate) => void;
onCreateTemplate: (format: 'video' | 'image', aspect: ExpressTemplate['aspectRatio']) => void;
onEditTemplate: (template: ExpressTemplate) => void;
onDuplicateTemplate: (template: ExpressTemplate) => void;
onDeleteTemplate: (id: string) => void;
}
/**
* TemplatesPanel — Top-left panel showing a searchable, draggable grid of templates.
*/
export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({
templates,
onSelect,
onCreateTemplate,
onEditTemplate,
onDuplicateTemplate,
onDeleteTemplate,
}) => {
const [search, setSearch] = useState('');
const [popoverOpen, setPopoverOpen] = useState(false);
const [customOpen, setCustomOpen] = useState(true);
const [presetOpen, setPresetOpen] = useState(true);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
if (!search.trim()) return templates;
const q = search.toLowerCase();
return templates.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.category.toLowerCase().includes(q)
);
}, [templates, search]);
const customTemplates = useMemo(() => filtered.filter(t => t.isCustom === true), [filtered]);
const presetTemplates = useMemo(() => filtered.filter(t => !t.isCustom), [filtered]);
// ── Close popover on click outside ──
useEffect(() => {
if (!popoverOpen) return;
const handleMouseDown = (e: MouseEvent) => {
if (
popoverRef.current && !popoverRef.current.contains(e.target as Node) &&
buttonRef.current && !buttonRef.current.contains(e.target as Node)
) {
setPopoverOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [popoverOpen]);
// ── Close popover on Escape ──
useEffect(() => {
if (!popoverOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setPopoverOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [popoverOpen]);
// ── Auto-focus first interactive element when popover opens ──
useEffect(() => {
if (popoverOpen && popoverRef.current) {
const first = popoverRef.current.querySelector<HTMLElement>('button, [tabindex]');
first?.focus();
}
}, [popoverOpen]);
return (
<div className="flex-1 min-w-0 bg-neutral-900/50 border border-neutral-800/50 rounded-2xl overflow-hidden flex flex-col relative">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<Layers size={16} className="text-violet-400" />
<h2 className="text-sm font-bold text-white">Plantillas</h2>
<span className="text-[10px] bg-neutral-800 text-neutral-400 px-1.5 py-0.5 rounded-full font-mono">
{templates.length}
</span>
</div>
<button
ref={buttonRef}
onClick={() => setPopoverOpen(prev => !prev)}
title="Crear nueva plantilla"
aria-expanded={popoverOpen}
aria-haspopup="dialog"
className={`flex items-center gap-1 text-[10px] font-semibold px-2 py-1 rounded-lg transition-all ${
popoverOpen
? 'text-violet-300 bg-violet-500/20 border border-violet-500/40'
: 'text-violet-400 hover:text-violet-300 bg-violet-500/10 hover:bg-violet-500/20 border border-violet-500/20 hover:border-violet-500/40'
}`}
>
<Plus size={11} /> Nueva
</button>
</div>
{/* Creation Popover */}
{popoverOpen && (
<CreateTemplatePopover
ref={popoverRef}
onCreateTemplate={(format, aspect) => {
onCreateTemplate(format, aspect);
setPopoverOpen(false);
}}
onClose={() => {
setPopoverOpen(false);
buttonRef.current?.focus();
}}
/>
)}
{/* Search */}
<div className="px-4 pb-3">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar plantilla..."
className="w-full bg-neutral-800/60 border border-neutral-700/50 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-violet-500/40 transition-colors"
/>
</div>
</div>
{/* Grid — split into custom and preset sections */}
<div className="flex-1 overflow-y-auto px-4 pb-4 custom-scrollbar">
{/* ── Mis plantillas (custom) ── */}
{customTemplates.length > 0 && (
<div className="mb-3">
<button
onClick={() => setCustomOpen(prev => !prev)}
title={customOpen ? 'Colapsar mis plantillas' : 'Expandir mis plantillas'}
className="flex items-center gap-1.5 w-full mb-2 group"
>
{customOpen ? (
<ChevronDown size={10} className="text-neutral-500 group-hover:text-violet-400 transition-colors shrink-0" />
) : (
<ChevronRight size={10} className="text-neutral-500 group-hover:text-violet-400 transition-colors shrink-0" />
)}
<Star size={10} className="text-violet-400 shrink-0" />
<span className="text-[9px] uppercase tracking-wider font-semibold text-neutral-400 group-hover:text-violet-400 transition-colors">
Mis plantillas
</span>
<span className="text-[9px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{customTemplates.length}
</span>
</button>
{customOpen && (
<div className="grid grid-cols-2 gap-2">
{customTemplates.map(template => (
<DraggableTemplate
key={template.id}
template={template}
onSelect={onSelect}
onEditTemplate={onEditTemplate}
onDuplicateTemplate={onDuplicateTemplate}
onDeleteTemplate={onDeleteTemplate}
/>
))}
</div>
)}
</div>
)}
{/* Separator between sections */}
{customTemplates.length > 0 && presetTemplates.length > 0 && (
<hr className="border-neutral-800/50 my-2" />
)}
{/* ── Predeterminadas (preset) ── */}
{presetTemplates.length > 0 && (
<div>
<button
onClick={() => setPresetOpen(prev => !prev)}
title={presetOpen ? 'Colapsar predeterminadas' : 'Expandir predeterminadas'}
className="flex items-center gap-1.5 w-full mb-2 group"
>
{presetOpen ? (
<ChevronDown size={10} className="text-neutral-500 group-hover:text-neutral-300 transition-colors shrink-0" />
) : (
<ChevronRight size={10} className="text-neutral-500 group-hover:text-neutral-300 transition-colors shrink-0" />
)}
<Package size={10} className="text-neutral-500 shrink-0" />
<span className="text-[9px] uppercase tracking-wider font-semibold text-neutral-500 group-hover:text-neutral-300 transition-colors">
Predeterminadas
</span>
<span className="text-[9px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{presetTemplates.length}
</span>
</button>
{presetOpen && (
<div className="grid grid-cols-2 gap-2">
{presetTemplates.map(template => (
<DraggableTemplate
key={template.id}
template={template}
onSelect={onSelect}
onEditTemplate={onEditTemplate}
onDuplicateTemplate={onDuplicateTemplate}
onDeleteTemplate={onDeleteTemplate}
/>
))}
</div>
)}
</div>
)}
{filtered.length === 0 && search.trim() && (
<div className="text-center py-6 text-neutral-600">
<Search size={20} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">Sin resultados para "{search}"</p>
</div>
)}
{templates.length === 0 && (
<div className="text-center py-8 text-neutral-600">
<Layers size={24} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">No hay plantillas creadas</p>
<p className="text-[10px] mt-1 text-neutral-700">Haz clic en "Nueva" para empezar</p>
</div>
)}
</div>
</div>
);
};
/* ── Create Template Popover ── */
const CreateTemplatePopover = React.forwardRef<
HTMLDivElement,
{
onCreateTemplate: (format: 'video' | 'image', aspect: ExpressTemplate['aspectRatio']) => void;
onClose: () => void;
}
>(({ onCreateTemplate, onClose }, ref) => {
const [selectedFormat, setSelectedFormat] = useState<'video' | 'image'>('image');
const [selectedAspect, setSelectedAspect] = useState<ExpressTemplate['aspectRatio']>('9:16');
// Focus trap: cycle focus within the popover
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== 'Tab') return;
const popover = (ref as React.RefObject<HTMLDivElement>)?.current;
if (!popover) return;
const focusable = popover.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}, [ref]);
return (
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-label="Nueva plantilla"
onKeyDown={handleKeyDown}
className="absolute right-4 top-[44px] z-50 w-[280px] p-4 bg-neutral-900 border border-neutral-700/60 rounded-xl shadow-2xl shadow-black/50 animate-in fade-in slide-in-from-top-1 duration-150 space-y-3"
>
{/* Title */}
<p className="text-sm font-bold text-white">Nueva plantilla</p>
{/* Format selector */}
<div className="space-y-1.5">
<p className="text-[10px] text-neutral-500 font-semibold uppercase tracking-wider">Tipo</p>
<div className="flex gap-1 p-0.5 bg-neutral-800/60 rounded-lg border border-neutral-700/40">
<button
onClick={() => setSelectedFormat('image')}
title="Formato imagen"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-semibold transition-all ${
selectedFormat === 'image'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<ImageIcon size={13} /> Imagen
</button>
<button
onClick={() => setSelectedFormat('video')}
title="Formato video"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-semibold transition-all ${
selectedFormat === 'video'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<Video size={13} /> Video
</button>
</div>
</div>
{/* Aspect ratio selector */}
<div className="space-y-1.5">
<p className="text-[10px] text-neutral-500 font-semibold uppercase tracking-wider">Aspecto</p>
<div className="grid grid-cols-2 gap-1.5">
{ASPECTS.map(a => {
const isSelected = selectedAspect === a.value;
return (
<button
key={a.value}
onClick={() => setSelectedAspect(a.value)}
title={`${a.label}${a.desc}`}
className={`group flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all ${
isSelected
? 'border-violet-500/60 bg-violet-950/30 text-violet-300'
: 'border-neutral-700/50 bg-neutral-800/40 hover:bg-neutral-800 hover:border-neutral-600/60 text-neutral-400'
}`}
>
{/* Aspect ratio visual thumbnail */}
<div
className={`rounded border shrink-0 transition-colors ${
isSelected ? 'border-violet-500/60' : 'border-neutral-600 group-hover:border-neutral-500'
}`}
style={{
width: a.value === '16:9' ? 28 : a.value === '1:1' ? 18 : a.value === '4:5' ? 16 : 14,
height: a.value === '9:16' ? 24 : a.value === '1:1' ? 18 : a.value === '4:5' ? 20 : 16,
backgroundColor: isSelected ? 'rgba(139,92,246,0.15)' : 'rgba(255,255,255,0.04)',
}}
/>
<div className="text-left min-w-0">
<span className={`text-[10px] font-bold block transition-colors ${
isSelected ? 'text-violet-300' : 'text-white group-hover:text-violet-300'
}`}>
{a.label}
</span>
<span className="text-[8px] text-neutral-500 block truncate">{a.desc}</span>
</div>
</button>
);
})}
</div>
</div>
{/* Create button */}
<button
onClick={() => onCreateTemplate(selectedFormat, selectedAspect)}
title="Crear plantilla con los parámetros seleccionados"
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white text-xs font-bold transition-all shadow-lg shadow-violet-900/30 hover:shadow-violet-900/50"
>
Crear plantilla <ArrowRight size={13} />
</button>
</div>
);
});
CreateTemplatePopover.displayName = 'CreateTemplatePopover';
/* ── Draggable template thumbnail ── */
const DraggableTemplate: React.FC<{
template: ExpressTemplate;
onSelect: (t: ExpressTemplate) => void;
onEditTemplate: (t: ExpressTemplate) => void;
onDuplicateTemplate: (t: ExpressTemplate) => void;
onDeleteTemplate: (id: string) => void;
}> = ({ template, onSelect, onEditTemplate, onDuplicateTemplate, onDeleteTemplate }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: `template-${template.id}`,
data: { type: 'template', template },
});
const style = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : 1,
};
const isCustom = template.isCustom === true;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onClick={() => onSelect(template)}
title={template.description || template.name}
className={`
group relative rounded-xl border overflow-hidden cursor-grab active:cursor-grabbing
transition-all duration-150
${isDragging
? 'border-violet-500/60 shadow-xl shadow-violet-900/30 z-50'
: 'border-neutral-800/60 bg-neutral-950/50 hover:border-violet-500/30 hover:shadow-md hover:shadow-violet-900/10'
}
`}
>
{/* Preview area */}
<div className="h-[72px] relative flex items-center justify-center bg-neutral-900/80">
<span className="text-2xl opacity-60 group-hover:opacity-100 group-hover:scale-110 transition-all">
{template.icon}
</span>
{/* Format badge */}
<div className={`absolute top-1.5 right-1.5 px-1 py-0.5 rounded text-[7px] font-bold ${
template.format === 'video'
? 'bg-violet-500/20 text-violet-300'
: 'bg-sky-500/20 text-sky-300'
}`}>
{template.format === 'video' ? '🎬' : '🖼️'} {template.aspectRatio}
</div>
{/* Drag grip hint */}
<div className="absolute top-1.5 left-1.5 opacity-0 group-hover:opacity-60 transition-opacity">
<GripVertical size={10} className="text-neutral-500" />
</div>
</div>
{/* Info */}
<div className="px-2.5 py-2 bg-neutral-950/80">
<p className="text-[10px] font-bold text-white truncate group-hover:text-violet-300 transition-colors">
{template.name}
</p>
<p className="text-[8px] text-neutral-500 truncate mt-0.5">
{template.description || `${template.category} · ${template.scenes.length} escenas`}
</p>
</div>
{/* Hover actions */}
<div className="absolute bottom-1.5 right-1.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{isCustom && (
<button
onClick={(e) => { e.stopPropagation(); onEditTemplate(template); }}
title="Editar plantilla"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Pencil size={9} />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); onDuplicateTemplate(template); }}
title="Duplicar plantilla"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Copy size={9} />
</button>
{isCustom && (
<button
onClick={(e) => { e.stopPropagation(); onDeleteTemplate(template.id); }}
title="Eliminar plantilla"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-red-400 rounded transition-colors"
>
<Trash2 size={9} />
</button>
)}
</div>
</div>
);
};
/**
* DragOverlay content for a template being dragged.
* Used by the parent DndContext's DragOverlay.
*/
export const TemplateDragPreview: React.FC<{ template: ExpressTemplate }> = ({ template }) => (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-neutral-800/95 border border-violet-500/50 shadow-2xl shadow-violet-900/40 backdrop-blur-sm">
<span className="text-xl">{template.icon}</span>
<div>
<p className="text-xs font-bold text-white">{template.name}</p>
<p className="text-[9px] text-violet-400">{template.format === 'video' ? '🎬 Video' : '🖼️ Imagen'} · {template.aspectRatio}</p>
</div>
</div>
);
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import { Download, Loader2, CheckCircle2, XCircle, Clock, Trash2, X } from 'lucide-react';
import type { RenderJobClient } from '../../hooks/useExportQueue';
interface ExportJobItemProps {
job: RenderJobClient;
onCancel: (id: string) => void;
onDownload: (job: RenderJobClient) => void;
}
/**
* Individual export job card with progress bar, status badge, and actions.
*/
export const ExportJobItem: React.FC<ExportJobItemProps> = ({ job, onCancel, onDownload }) => {
const elapsed = job.startedAt
? ((job.completedAt ?? Date.now()) - job.startedAt) / 1000
: 0;
const formatLabel = {
mp4: 'MP4 Video',
webm: 'WebM Video',
gif: 'GIF Animación',
png: 'PNG Image',
jpeg: 'JPEG Image',
}[job.format];
const statusConfig = {
queued: { icon: Clock, color: 'text-amber-400', bg: 'bg-amber-500/10', label: 'En cola' },
rendering: { icon: Loader2, color: 'text-violet-400', bg: 'bg-violet-500/10', label: 'Renderizando' },
done: { icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10', label: 'Completado' },
error: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/10', label: 'Error' },
}[job.status];
const StatusIcon = statusConfig.icon;
return (
<div className="bg-neutral-900/80 border border-neutral-800/60 rounded-xl p-3 space-y-2.5 transition-all hover:border-neutral-700/60">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusIcon
size={14}
className={`${statusConfig.color} ${job.status === 'rendering' ? 'animate-spin' : ''}`}
/>
<span className="text-[11px] font-semibold text-white">{formatLabel}</span>
<span className="text-[9px] font-mono text-neutral-600">{job.id.slice(0, 8)}</span>
</div>
<div className="flex items-center gap-1">
{job.status === 'done' && (
<button
onClick={() => onDownload(job)}
title="Descargar"
className="p-1 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
>
<Download size={12} />
</button>
)}
{(job.status === 'queued' || job.status === 'rendering') && (
<button
onClick={() => onCancel(job.id)}
title="Cancelar"
className="p-1 rounded-md text-neutral-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X size={12} />
</button>
)}
{(job.status === 'done' || job.status === 'error') && (
<button
onClick={() => onCancel(job.id)}
title="Eliminar"
className="p-1 rounded-md text-neutral-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={10} />
</button>
)}
</div>
</div>
{/* Progress bar */}
{(job.status === 'rendering' || job.status === 'queued') && (
<div className="space-y-1">
<div className="h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
job.status === 'queued'
? 'bg-amber-500/50 animate-pulse'
: 'bg-gradient-to-r from-violet-500 to-fuchsia-500'
}`}
style={{ width: `${Math.max(job.status === 'queued' ? 5 : job.progress, 2)}%` }}
/>
</div>
<div className="flex items-center justify-between text-[9px] text-neutral-500">
<span>{job.progress}%</span>
{job.renderedFrames != null && job.totalFrames != null && (
<span>{job.renderedFrames}/{job.totalFrames} frames</span>
)}
</div>
</div>
)}
{/* Status badge */}
<div className="flex items-center justify-between">
<span className={`text-[9px] px-2 py-0.5 rounded-full font-medium ${statusConfig.bg} ${statusConfig.color}`}>
{statusConfig.label}
</span>
<div className="flex items-center gap-2 text-[9px] text-neutral-600">
<span>{job.width}×{job.height}</span>
{job.fps && <span>{job.fps}fps</span>}
{elapsed > 0 && <span>{elapsed.toFixed(1)}s</span>}
</div>
</div>
{/* Error message */}
{job.error && (
<div className="text-[10px] text-red-400/80 bg-red-500/5 rounded-md px-2 py-1.5 border border-red-500/10">
{job.error}
</div>
)}
</div>
);
};
+320
View File
@@ -0,0 +1,320 @@
import React, { useState, useMemo } from 'react';
import { X, Download, Film, Image as ImageIcon, Wifi, WifiOff, Zap, ChevronDown } from 'lucide-react';
import { useExportQueue, RenderFormat, ExportConfig } from '../../hooks/useExportQueue';
import { ExportJobItem } from './ExportJobItem';
import type { DesignMD, TimelineElement, TimelineLayer } from '../../types';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
designMD: DesignMD;
textOverlay: string;
timelineElements: TimelineElement[];
layers: TimelineLayer[];
durationInFrames: number;
brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
outputFormat?: 'video' | 'image';
/** Template aspect ratio — used to filter resolution presets */
aspectRatio?: '9:16' | '16:9' | '1:1' | '4:5' | '4:3';
}
const FORMAT_OPTIONS: { value: RenderFormat; label: string; icon: typeof Film; desc: string }[] = [
{ value: 'mp4', label: 'MP4', icon: Film, desc: 'Compatible con todo' },
{ value: 'webm', label: 'WebM', icon: Film, desc: 'Web optimizado' },
{ value: 'gif', label: 'GIF', icon: Film, desc: 'Animación ligera' },
{ value: 'png', label: 'PNG', icon: ImageIcon, desc: 'Imagen sin fondo' },
{ value: 'jpeg', label: 'JPEG', icon: ImageIcon, desc: 'Imagen comprimida' },
];
const RESOLUTION_PRESETS = [
{ label: '1080×1080', w: 1080, h: 1080, desc: 'Instagram Post', ratio: '1:1' },
{ label: '720×720', w: 720, h: 720, desc: 'Preview rápido', ratio: '1:1' },
{ label: '1080×1920', w: 1080, h: 1920, desc: 'Story / Reel', ratio: '9:16' },
{ label: '720×1280', w: 720, h: 1280, desc: 'Preview rápido', ratio: '9:16' },
{ label: '1920×1080', w: 1920, h: 1080, desc: 'YouTube / TV', ratio: '16:9' },
{ label: '1280×720', w: 1280, h: 720, desc: 'HD 720p', ratio: '16:9' },
{ label: '1080×1350', w: 1080, h: 1350, desc: 'Feed 4:5', ratio: '4:5' },
{ label: '720×900', w: 720, h: 900, desc: 'Preview 4:5', ratio: '4:5' },
{ label: '1440×1080', w: 1440, h: 1080, desc: 'Pantalla 4:3', ratio: '4:3' },
{ label: '960×720', w: 960, h: 720, desc: 'Preview 4:3', ratio: '4:3' },
];
/**
* Export modal — format selection, resolution presets, and live job queue.
*/
export const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
designMD,
textOverlay,
timelineElements,
layers,
durationInFrames,
brandVisibility,
outputFormat,
aspectRatio,
}) => {
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
const [format, setFormat] = useState<RenderFormat>('mp4');
const [fps, setFps] = useState(30);
const [isExporting, setIsExporting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [quality, setQuality] = useState<'draft' | 'standard' | 'high' | 'ultra'>('high');
const isStill = format === 'png' || format === 'jpeg';
// Filter resolution presets to match the template's aspect ratio
const filteredPresets = useMemo(() => {
if (!aspectRatio) return RESOLUTION_PRESETS;
const matching = RESOLUTION_PRESETS.filter(p => p.ratio === aspectRatio);
return matching.length > 0 ? matching : RESOLUTION_PRESETS;
}, [aspectRatio]);
const [resIdx, setResIdx] = useState(0);
const selectedRes = filteredPresets[resIdx] || filteredPresets[0];
// Estimated file size
const estimatedSize = useMemo(() => {
if (isStill) return '~0.5 MB';
const seconds = durationInFrames / fps;
const pixels = selectedRes.w * selectedRes.h;
const bitrateMap: Record<string, number> = { mp4: 5, webm: 3, gif: 15 };
const bitrate = bitrateMap[format] ?? 5; // MB per minute per megapixel
const mp = pixels / 1_000_000;
const sizeMB = (seconds / 60) * bitrate * mp;
return sizeMB < 1 ? `~${Math.round(sizeMB * 1024)} KB` : `~${sizeMB.toFixed(1)} MB`;
}, [format, selectedRes, durationInFrames, fps, isStill]);
// Auto-select image format in image mode
const filteredFormats = useMemo(() => {
if (outputFormat === 'image') {
return FORMAT_OPTIONS.filter(f => f.value === 'png' || f.value === 'jpeg');
}
return FORMAT_OPTIONS;
}, [outputFormat]);
const handleExport = async () => {
setIsExporting(true);
try {
const config: ExportConfig = {
format,
width: selectedRes.w,
height: selectedRes.h,
fps,
durationInFrames: isStill ? 1 : durationInFrames,
designMD,
textOverlay,
timelineElements,
layers,
brandVisibility,
outputFormat,
};
await startExport(config);
} finally {
setIsExporting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl shadow-2xl shadow-black/80 w-[480px] max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-gradient-to-br from-violet-500/20 to-fuchsia-500/20 border border-violet-500/20">
<Download size={18} className="text-violet-400" />
</div>
<div>
<h2 className="text-sm font-bold text-white">Exportar</h2>
<p className="text-[10px] text-neutral-500">Renderizar y descargar</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5" title={isConnected ? 'Conectado al servidor' : 'Sin conexión'}>
{isConnected
? <Wifi size={12} className="text-emerald-400" />
: <WifiOff size={12} className="text-red-400" />
}
<span className={`text-[9px] ${isConnected ? 'text-emerald-400' : 'text-red-400'}`}>
{isConnected ? 'Live' : 'Offline'}
</span>
</div>
<button onClick={onClose} title="Cerrar" className="p-1.5 rounded-lg hover:bg-neutral-800 transition-colors text-neutral-400 hover:text-white">
<X size={16} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5 space-y-5">
{/* Format Selection */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Formato</label>
<div className="grid grid-cols-2 gap-2">
{filteredFormats.map(opt => {
const Icon = opt.icon;
return (
<button
key={opt.value}
onClick={() => setFormat(opt.value)}
title={opt.desc}
className={`p-3 rounded-xl border transition-all flex items-center gap-2.5 ${
format === opt.value
? 'bg-violet-600/15 border-violet-500/50 text-violet-300 shadow-lg shadow-violet-500/5'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-300'
}`}
>
<Icon size={16} />
<div className="text-left">
<div className="text-xs font-semibold">{opt.label}</div>
<div className="text-[9px] text-neutral-500">{opt.desc}</div>
</div>
</button>
);
})}
</div>
</div>
{/* Resolution */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Resolución</label>
<div className="flex flex-wrap gap-1.5">
{filteredPresets.map((preset, idx) => (
<button
key={preset.label}
onClick={() => setResIdx(idx)}
title={preset.desc}
className={`px-3 py-1.5 rounded-lg border text-[10px] font-medium transition-all ${
resIdx === idx
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
{preset.label}
</button>
))}
</div>
<p className="text-[9px] text-neutral-600 mt-1">{selectedRes.desc}</p>
</div>
{/* Advanced Settings */}
{!isStill && (
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1 text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
<ChevronDown size={10} className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
Configuración avanzada
</button>
{showAdvanced && (
<div className="mt-2 bg-neutral-900/50 border border-neutral-800/50 rounded-lg p-3 space-y-3">
<div>
<label className="block text-[10px] text-neutral-500 mb-1">FPS</label>
<div className="flex gap-1.5">
{[24, 30, 60].map(f => (
<button
key={f}
onClick={() => setFps(f)}
className={`px-3 py-1 rounded-md text-[10px] font-medium border transition-all ${
fps === f
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
{f} fps
</button>
))}
</div>
</div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-500">Duración</span>
<span className="text-neutral-400 font-mono">{(durationInFrames / fps).toFixed(1)}s ({durationInFrames} frames)</span>
</div>
{/* Quality Tier */}
<div>
<label className="block text-[10px] text-neutral-500 mb-1">Calidad</label>
<div className="grid grid-cols-4 gap-1">
{[
{ value: 'draft' as const, label: 'Draft', color: 'text-neutral-400' },
{ value: 'standard' as const, label: 'Std', color: 'text-sky-400' },
{ value: 'high' as const, label: 'High', color: 'text-violet-400' },
{ value: 'ultra' as const, label: 'Ultra', color: 'text-amber-400' },
].map(q => (
<button
key={q.value}
onClick={() => {
setQuality(q.value);
// Auto-adjust resolution: high/ultra → first (largest), draft/standard → last (smallest)
const lastIdx = filteredPresets.length - 1;
const resMap: Record<string, number> = { draft: lastIdx, standard: lastIdx, high: 0, ultra: 0 };
setResIdx(resMap[q.value] ?? 0);
const fpsMap: Record<string, number> = { draft: 24, standard: 30, high: 30, ultra: 60 };
setFps(fpsMap[q.value] ?? 30);
}}
title={q.label}
className={`py-1 rounded-md text-[9px] font-semibold border transition-all ${
quality === q.value
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: `bg-neutral-900 border-neutral-800 ${q.color} hover:border-neutral-700`
}`}
>
{q.label}
</button>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* Export Button */}
<button
onClick={handleExport}
disabled={isExporting || !isConnected}
title="Iniciar exportación"
className={`w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all ${
isExporting || !isConnected
? 'bg-neutral-800 text-neutral-500 cursor-not-allowed'
: 'bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white hover:from-violet-500 hover:to-fuchsia-500 shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30'
}`}
>
<Zap size={16} />
{isExporting ? 'Iniciando...' : `Exportar ${isStill ? 'Imagen' : 'Video'}`}
<span className="text-[9px] opacity-60 font-mono">({estimatedSize})</span>
</button>
{/* Job Queue */}
{jobs.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">
Cola de Exportación
</label>
{hasActiveJobs && (
<span className="text-[9px] text-violet-400 animate-pulse">
{activeJobs.length} activ{activeJobs.length > 1 ? 'os' : 'o'}
</span>
)}
</div>
<div className="space-y-2 max-h-[250px] overflow-y-auto custom-scrollbar">
{jobs.map(job => (
<ExportJobItem
key={job.id}
job={job}
onCancel={cancelJob}
onDownload={downloadJob}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,59 @@
import React from 'react';
import { Clock } from 'lucide-react';
interface ExpressDurationPickerProps {
duration: number;
onChange: (seconds: number) => void;
isVideo: boolean;
}
const VIDEO_PRESETS = [5, 10, 15, 20, 30, 60];
/**
* ExpressDurationPicker — Simple duration selector with presets.
* Only visible for video templates (image duration is fixed at 1s).
*/
export const ExpressDurationPicker: React.FC<ExpressDurationPickerProps> = ({
duration,
onChange,
isVideo,
}) => {
if (!isVideo) return null;
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Clock size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Duración</span>
<span className="text-[9px] text-violet-400 font-mono ml-auto">{duration}s</span>
</div>
<div className="grid grid-cols-3 gap-1">
{VIDEO_PRESETS.map(s => (
<button
key={s}
onClick={() => onChange(s)}
title={`${s} segundos`}
className={`py-1.5 rounded-lg text-[10px] font-semibold border transition-all ${
duration === s
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-white hover:border-neutral-700'
}`}
>
{s}s
</button>
))}
</div>
{/* Visual bar */}
<div className="flex gap-0.5 h-1.5 rounded-full overflow-hidden">
<div className="bg-amber-500/40 rounded-l-full" style={{ width: '15%' }} title="Intro" />
<div className="bg-violet-500/40 flex-1" title="Contenido" />
<div className="bg-amber-500/40 rounded-r-full" style={{ width: '15%' }} title="Outro" />
</div>
<div className="flex justify-between text-[7px] text-neutral-600 px-1">
<span>Intro</span>
<span>Contenido</span>
<span>Outro</span>
</div>
</div>
);
};
+328
View File
@@ -0,0 +1,328 @@
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { ArrowLeft, Zap, Wrench, Download, ChevronRight, Play, Pause, RotateCcw } from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player';
import { ExpressTemplate, DesignMD, TimelineElement, TimelineLayer, CompanyProfile } from '../../types';
import { BrandComposition } from '../BrandComposition';
import { ExpressTemplateGallery } from './ExpressTemplateGallery';
import { StoryboardView } from './StoryboardView';
import { SceneFieldEditor } from './SceneFieldEditor';
import { ExpressStylePanel } from './ExpressStylePanel';
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
interface ExpressEditorProps {
designMD: DesignMD;
company?: CompanyProfile;
onBack: () => void;
onUpgradeToPro: (elements: TimelineElement[], layers: TimelineLayer[]) => void;
onExport: (elements: TimelineElement[], layers: TimelineLayer[], format: 'video' | 'image') => void;
}
type EditorPhase = 'gallery' | 'editing';
/**
* ExpressEditor — Scene-based storyboard editor.
* No video editor, no timeline, no toolbar.
* User picks a template → fills in scenes → exports.
*/
export const ExpressEditor: React.FC<ExpressEditorProps> = ({
designMD,
company,
onBack,
onUpgradeToPro,
onExport,
}) => {
const [phase, setPhase] = useState<EditorPhase>('gallery');
const [selectedTemplate, setSelectedTemplate] = useState<ExpressTemplate | null>(null);
const [fieldData, setFieldData] = useState<Record<string, string>>({});
const [activeSceneId, setActiveSceneId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// Style options
const [bgStyle, setBgStyle] = useState<'solid' | 'gradient' | 'dark'>('gradient');
const [showLogo, setShowLogo] = useState(true);
const [overlayOpacity, setOverlayOpacity] = useState(0);
const playerRef = useRef<PlayerRef>(null);
const handleSelectTemplate = useCallback((template: ExpressTemplate) => {
setSelectedTemplate(template);
// Pre-fill field data with empty strings
const initial: Record<string, string> = {};
template.scenes.forEach(scene => {
scene.editableFields.forEach(field => {
initial[field.id] = '';
});
});
setFieldData(initial);
setActiveSceneId(template.scenes[0]?.id || null);
setPhase('editing');
}, []);
const handleFieldChange = useCallback((fieldId: string, value: string) => {
setFieldData(prev => ({ ...prev, [fieldId]: value }));
}, []);
// Compile template to timeline
const compiled = useMemo(() => {
if (!selectedTemplate) return null;
return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company);
}, [selectedTemplate, fieldData, designMD, company]);
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate) : 0;
const fps = 30;
const totalFrames = Math.max(30, totalDuration * fps);
const dimensions = selectedTemplate
? getAspectDimensions(selectedTemplate.aspectRatio)
: { w: 1080, h: 1920 };
const activeScene = selectedTemplate?.scenes.find(s => s.id === activeSceneId) || null;
const handlePlayToggle = useCallback(() => {
if (playerRef.current) {
if (isPlaying) {
playerRef.current.pause();
} else {
playerRef.current.play();
}
setIsPlaying(!isPlaying);
}
}, [isPlaying]);
const handleUpgrade = () => {
if (compiled) onUpgradeToPro(compiled.elements, compiled.layers);
};
const handleExport = () => {
if (compiled && selectedTemplate) {
onExport(compiled.elements, compiled.layers, selectedTemplate.format);
}
};
// Navigate to scene in player
const handleSelectScene = useCallback((sceneId: string) => {
setActiveSceneId(sceneId);
if (!selectedTemplate || !playerRef.current) return;
// Seek player to scene start
let frameOffset = 0;
for (const scene of selectedTemplate.scenes) {
if (scene.id === sceneId) break;
frameOffset += scene.durationSeconds * fps;
}
playerRef.current.seekTo(frameOffset);
playerRef.current.pause();
setIsPlaying(false);
}, [selectedTemplate, fps]);
const bgColor = bgStyle === 'dark'
? '#111111'
: bgStyle === 'gradient'
? undefined
: designMD.secondaryColor;
return (
<div className="flex-1 flex flex-col overflow-hidden bg-neutral-950">
{/* ═══ Top Bar ═══ */}
<div className="h-11 bg-neutral-900/80 border-b border-neutral-800/60 flex items-center px-4 gap-3 shrink-0 backdrop-blur-sm">
<button
onClick={phase === 'editing' ? () => setPhase('gallery') : onBack}
title="Volver"
className="flex items-center gap-1.5 text-neutral-400 hover:text-white transition-colors text-xs"
>
<ArrowLeft size={14} />
{phase === 'editing' ? 'Plantillas' : 'Dashboard'}
</button>
<div className="flex-1" />
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-gradient-to-r from-violet-600/15 to-fuchsia-600/15 border border-violet-500/20">
<Zap size={12} className="text-violet-400" />
<span className="text-[10px] font-bold text-violet-300 tracking-wider">EXPRESS</span>
</div>
{phase === 'editing' && (
<>
<button
onClick={handleUpgrade}
title="Abrir en Editor Pro con timeline completo"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-neutral-800 border border-neutral-700 text-[10px] text-neutral-400 hover:text-white hover:border-neutral-600 transition-all"
>
<Wrench size={10} />
Editor Pro
<ChevronRight size={10} />
</button>
<button
onClick={handleExport}
title="Exportar"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white text-[10px] font-semibold hover:from-violet-500 hover:to-fuchsia-500 transition-all shadow-lg shadow-violet-900/30"
>
<Download size={12} />
Exportar
</button>
</>
)}
</div>
{/* ═══ Content ═══ */}
{phase === 'gallery' ? (
<ExpressTemplateGallery
designMD={designMD}
onSelectTemplate={handleSelectTemplate}
brandTemplates={company?.brandTemplates}
brandName={company?.name}
/>
) : selectedTemplate && compiled ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Main area: Preview + Right Panel */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Canvas Area */}
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Subtle pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Template name */}
<div className="mb-2 flex items-center gap-2 relative z-10 shrink-0">
<span className="text-lg">{selectedTemplate.icon}</span>
<span className="text-xs font-semibold text-neutral-400">{selectedTemplate.name}</span>
<span className="text-[9px] text-neutral-600 font-mono px-1.5 py-0.5 bg-neutral-900 rounded">
{selectedTemplate.aspectRatio}
</span>
<span className="text-[9px] text-neutral-600 font-mono px-1.5 py-0.5 bg-neutral-900 rounded">
{totalDuration}s
</span>
</div>
{/* Player */}
<div
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 shrink-0"
style={{
width: selectedTemplate.aspectRatio === '9:16' ? 240
: selectedTemplate.aspectRatio === '1:1' ? 320
: selectedTemplate.aspectRatio === '4:5' ? 280
: 420,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100% - 80px)',
}}
>
<Player
ref={playerRef}
component={BrandComposition}
inputProps={{
designMD: {
...designMD,
secondaryColor: bgColor || designMD.secondaryColor,
},
timelineElements: compiled.elements,
layers: compiled.layers,
selectedElementId: null,
aspectRatio: selectedTemplate.aspectRatio,
textOverlay: '',
showLogo,
showFrame: false,
showBackground: true,
brandVisibility: {
logo: showLogo,
frame: false,
background: true,
},
}}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={fps}
style={{ width: '100%', height: '100%' }}
controls={false}
autoPlay={false}
loop
/>
{overlayOpacity > 0 && (
<div
className="absolute inset-0 pointer-events-none"
style={{ backgroundColor: `rgba(0,0,0,${overlayOpacity / 100})` }}
/>
)}
</div>
{/* Mini play controls */}
{selectedTemplate.format === 'video' && (
<div className="mt-3 flex items-center gap-2 relative z-10 shrink-0">
<button
onClick={handlePlayToggle}
title={isPlaying ? 'Pausar' : 'Reproducir'}
className="w-7 h-7 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-sm"
>
{isPlaying ? <Pause size={11} fill="currentColor" /> : <Play size={11} fill="currentColor" />}
</button>
<button
onClick={() => { playerRef.current?.seekTo(0); setIsPlaying(false); }}
title="Reiniciar"
className="w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
>
<RotateCcw size={10} />
</button>
<span className="text-[9px] text-neutral-500 font-mono">{totalDuration}s</span>
</div>
)}
</div>
{/* Right Panel — Scene Fields */}
<aside className="w-72 bg-neutral-900 border-l border-neutral-800/60 overflow-y-auto p-4 space-y-5 shrink-0">
{activeScene ? (
<SceneFieldEditor
scene={activeScene}
fieldData={fieldData}
onFieldChange={handleFieldChange}
designMD={designMD}
/>
) : (
<div className="text-center text-neutral-500 text-xs py-8">
Selecciona una escena del storyboard
</div>
)}
<hr className="border-neutral-800/50" />
{/* Style */}
<ExpressStylePanel
designMD={designMD}
bgStyle={bgStyle}
setBgStyle={setBgStyle}
showLogo={showLogo}
setShowLogo={setShowLogo}
overlayOpacity={overlayOpacity}
setOverlayOpacity={setOverlayOpacity}
/>
<hr className="border-neutral-800/50" />
<button
onClick={() => setPhase('gallery')}
title="Elegir otra plantilla"
className="w-full py-2 rounded-lg bg-neutral-800/50 border border-neutral-800 text-[10px] text-neutral-400 hover:text-white hover:border-neutral-700 transition-all flex items-center justify-center gap-1.5"
>
<RotateCcw size={10} />
Cambiar plantilla
</button>
</aside>
</div>
{/* Storyboard (bottom strip — video only) */}
{selectedTemplate.format === 'video' && (
<StoryboardView
scenes={selectedTemplate.scenes}
activeSceneId={activeSceneId}
onSelectScene={handleSelectScene}
fieldData={fieldData}
totalDuration={totalDuration}
/>
)}
</div>
) : null}
</div>
);
};
@@ -0,0 +1,110 @@
import React from 'react';
import { Palette, Eye, EyeOff } from 'lucide-react';
import { DesignMD } from '../../types';
interface ExpressStylePanelProps {
designMD: DesignMD;
bgStyle: 'solid' | 'gradient' | 'dark';
setBgStyle: (style: 'solid' | 'gradient' | 'dark') => void;
showLogo: boolean;
setShowLogo: (show: boolean) => void;
overlayOpacity: number;
setOverlayOpacity: (opacity: number) => void;
}
/**
* ExpressStylePanel — Brand-constrained style controls.
* Only allows changes within the brand palette — no custom colors.
*/
export const ExpressStylePanel: React.FC<ExpressStylePanelProps> = ({
designMD,
bgStyle,
setBgStyle,
showLogo,
setShowLogo,
overlayOpacity,
setOverlayOpacity,
}) => {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<Palette size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Estilo</span>
</div>
{/* Brand colors (read-only) */}
<div className="flex items-center gap-2">
<span className="text-[9px] text-neutral-500">Paleta:</span>
{[designMD.primaryColor, designMD.secondaryColor, designMD.textColor].map((c, i) => (
<div
key={i}
className="w-5 h-5 rounded-md border border-neutral-700 shadow-sm"
style={{ backgroundColor: c }}
title={c}
/>
))}
<span className="text-[7px] text-neutral-600 ml-auto font-mono">{designMD.baseFont.split(',')[0].replace(/"/g, '')}</span>
</div>
{/* Background style */}
<div className="space-y-1">
<span className="text-[9px] text-neutral-500">Fondo</span>
<div className="grid grid-cols-3 gap-1">
{([
{ value: 'solid' as const, label: 'Sólido', preview: designMD.secondaryColor },
{ value: 'gradient' as const, label: 'Degradado', preview: `linear-gradient(135deg, ${designMD.primaryColor}, ${designMD.secondaryColor})` },
{ value: 'dark' as const, label: 'Oscuro', preview: '#111111' },
]).map(bg => (
<button
key={bg.value}
onClick={() => setBgStyle(bg.value)}
title={bg.label}
className={`relative h-8 rounded-lg border overflow-hidden transition-all ${
bgStyle === bg.value
? 'border-violet-500/60 ring-1 ring-violet-500/20'
: 'border-neutral-800 hover:border-neutral-700'
}`}
>
<div
className="absolute inset-0"
style={{ background: bg.preview }}
/>
<span className="relative text-[8px] font-semibold text-white drop-shadow-md">{bg.label}</span>
</button>
))}
</div>
</div>
{/* Overlay opacity */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[9px] text-neutral-500">Overlay</span>
<span className="text-[8px] text-neutral-600 font-mono">{overlayOpacity}%</span>
</div>
<input
type="range"
min={0}
max={80}
value={overlayOpacity}
onChange={(e) => setOverlayOpacity(Number(e.target.value))}
className="w-full h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
title="Opacidad del overlay oscuro"
/>
</div>
{/* Logo toggle */}
<button
onClick={() => setShowLogo(!showLogo)}
title={showLogo ? 'Ocultar logo' : 'Mostrar logo'}
className={`w-full flex items-center justify-between py-1.5 px-2.5 rounded-lg border transition-all ${
showLogo
? 'bg-violet-600/10 border-violet-500/30 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<span className="text-[9px] font-medium">Logo de marca</span>
{showLogo ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
</div>
);
};
@@ -0,0 +1,252 @@
import React, { useState, useMemo } from 'react';
import { Sparkles, Filter, Video, Image as ImageIcon } from 'lucide-react';
import { ExpressTemplate, DesignMD } from '../../types';
import { EXPRESS_TEMPLATES } from '../../config/expressTemplates';
import { getTemplateDuration } from '../../utils/expressCompiler';
interface ExpressTemplateGalleryProps {
designMD: DesignMD;
onSelectTemplate: (template: ExpressTemplate) => void;
customTemplates?: ExpressTemplate[];
brandTemplates?: ExpressTemplate[];
brandName?: string;
}
type CategoryFilter = 'all' | ExpressTemplate['category'];
type FormatFilter = 'all' | 'video' | 'image';
const CATEGORY_LABELS: Record<string, { label: string; icon: string }> = {
all: { label: 'Todos', icon: '✨' },
social: { label: 'Social', icon: '📱' },
ad: { label: 'Publicidad', icon: '🎯' },
promo: { label: 'Promo', icon: '🚀' },
story: { label: 'Historia', icon: '💬' },
announcement: { label: 'Anuncio', icon: '📢' },
};
/**
* ExpressTemplateGallery — Grid of Express templates with category/format filters.
* Shows previews using brand colors and allows template selection.
*/
export const ExpressTemplateGallery: React.FC<ExpressTemplateGalleryProps> = ({
designMD,
onSelectTemplate,
customTemplates = [],
brandTemplates = [],
brandName,
}) => {
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const [formatFilter, setFormatFilter] = useState<FormatFilter>('all');
const allTemplates = useMemo(() => [
...EXPRESS_TEMPLATES,
...customTemplates,
], [customTemplates]);
const filtered = useMemo(() => {
return allTemplates.filter(t => {
if (categoryFilter !== 'all' && t.category !== categoryFilter) return false;
if (formatFilter !== 'all' && t.format !== formatFilter) return false;
return true;
});
}, [allTemplates, categoryFilter, formatFilter]);
return (
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-gradient-to-br from-violet-600/20 to-fuchsia-600/20 border border-violet-500/20">
<Sparkles size={20} className="text-violet-400" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Elige una Plantilla</h2>
<p className="text-xs text-neutral-500">
Se aplicarán los colores y fuentes de <span className="text-violet-400">{designMD.brandName || 'tu marca'}</span> automáticamente
</p>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
{/* Format toggle */}
<div className="flex rounded-lg bg-neutral-900 border border-neutral-800 p-0.5 shrink-0">
{(['all', 'video', 'image'] as FormatFilter[]).map(f => (
<button
key={f}
onClick={() => setFormatFilter(f)}
title={f === 'all' ? 'Todos los formatos' : f === 'video' ? 'Solo video' : 'Solo imagen'}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-[10px] font-semibold transition-all ${
formatFilter === f
? 'bg-violet-600/20 text-violet-300 border border-violet-500/30'
: 'text-neutral-500 hover:text-neutral-300 border border-transparent'
}`}
>
{f === 'all' && <Filter size={10} />}
{f === 'video' && <Video size={10} />}
{f === 'image' && <ImageIcon size={10} />}
{f === 'all' ? 'Todo' : f === 'video' ? 'Video' : 'Imagen'}
</button>
))}
</div>
{/* Category pills */}
<div className="flex gap-1 flex-wrap">
{Object.entries(CATEGORY_LABELS).map(([key, { label, icon }]) => (
<button
key={key}
onClick={() => setCategoryFilter(key as CategoryFilter)}
title={`Filtrar por: ${label}`}
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-[9px] font-medium transition-all border ${
categoryFilter === key
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
<span>{icon}</span> {label}
</button>
))}
</div>
</div>
{/* Brand Templates Section */}
{brandTemplates.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-xs font-bold text-amber-300">
🏷 Plantillas de {brandName || 'tu marca'}
</h3>
<span className="text-[8px] text-neutral-600 bg-neutral-800 px-1.5 py-0.5 rounded font-mono">
{brandTemplates.length}
</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{brandTemplates.map(template => (
<button
key={template.id}
onClick={() => onSelectTemplate(template)}
title={template.description}
className="group relative bg-amber-500/5 border border-amber-500/20 rounded-xl overflow-hidden hover:border-amber-400/50 hover:shadow-lg hover:shadow-amber-900/10 transition-all hover:scale-[1.02] active:scale-[0.98] text-left"
>
<div
className="h-32 relative flex items-center justify-center overflow-hidden"
style={{
background: `linear-gradient(135deg, ${designMD.secondaryColor}40 0%, ${designMD.primaryColor}20 100%)`,
}}
>
<span className="text-3xl opacity-60 group-hover:opacity-100 transition-all">{template.icon}</span>
<div className="absolute top-2 left-2 px-1.5 py-0.5 rounded bg-amber-500/20 text-[8px] text-amber-300 font-bold">
🏷 {brandName}
</div>
<div className={`absolute top-2 right-2 px-1.5 py-0.5 rounded text-[8px] font-semibold backdrop-blur-sm ${
template.format === 'video' ? 'bg-violet-500/20 text-violet-300' : 'bg-sky-500/20 text-sky-300'
}`}>
{template.format === 'video' ? '🎬' : '🖼️'} {template.aspectRatio}
</div>
</div>
<div className="p-3">
<h4 className="text-xs font-bold text-white group-hover:text-amber-300 transition-colors">{template.name}</h4>
<p className="text-[9px] text-neutral-500 mt-0.5 line-clamp-1">{template.description}</p>
</div>
</button>
))}
</div>
</div>
)}
{/* General Templates */}
{brandTemplates.length > 0 && (
<h3 className="text-xs font-bold text-neutral-400">Plantillas Generales</h3>
)}
{/* Template Grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map(template => (
<button
key={template.id}
onClick={() => onSelectTemplate(template)}
title={template.description}
className="group relative bg-neutral-900/60 border border-neutral-800/60 rounded-xl overflow-hidden hover:border-violet-500/40 hover:shadow-lg hover:shadow-violet-900/10 transition-all hover:scale-[1.02] active:scale-[0.98] text-left"
>
{/* Preview area — branded colors */}
<div
className="h-40 relative flex items-center justify-center overflow-hidden"
style={{
background: `linear-gradient(135deg, ${designMD.secondaryColor}40 0%, ${designMD.primaryColor}20 100%)`,
}}
>
{/* Template icon */}
<span className="text-4xl opacity-60 group-hover:opacity-100 group-hover:scale-110 transition-all">
{template.icon}
</span>
{/* Aspect ratio badge */}
<div className="absolute top-2 left-2 px-1.5 py-0.5 rounded bg-black/40 backdrop-blur-sm text-[8px] text-neutral-300 font-mono">
{template.aspectRatio}
</div>
{/* Format badge */}
<div className={`absolute top-2 right-2 px-1.5 py-0.5 rounded text-[8px] font-semibold backdrop-blur-sm ${
template.format === 'video'
? 'bg-violet-500/20 text-violet-300'
: 'bg-sky-500/20 text-sky-300'
}`}>
{template.format === 'video' ? '🎬 Video' : '🖼️ Imagen'}
</div>
{/* Duration badge */}
{template.format === 'video' && (
<div className="absolute bottom-2 right-2 px-1.5 py-0.5 rounded bg-black/40 backdrop-blur-sm text-[8px] text-neutral-300 font-mono">
{getTemplateDuration(template)}s
</div>
)}
{/* Custom badge */}
{template.isCustom && (
<div className="absolute bottom-2 left-2 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[7px] font-bold uppercase tracking-wider">
Custom
</div>
)}
{/* Brand color dots */}
<div className="absolute bottom-2 left-2 flex gap-1">
{!template.isCustom && (
<>
<div className="w-2.5 h-2.5 rounded-full border border-white/20 shadow-sm" style={{ backgroundColor: designMD.primaryColor }} />
<div className="w-2.5 h-2.5 rounded-full border border-white/20 shadow-sm" style={{ backgroundColor: designMD.secondaryColor }} />
</>
)}
</div>
</div>
{/* Info */}
<div className="p-3">
<h3 className="text-xs font-bold text-white group-hover:text-violet-300 transition-colors">{template.name}</h3>
<p className="text-[9px] text-neutral-500 mt-0.5 line-clamp-1">{template.description}</p>
<div className="flex items-center gap-1.5 mt-2">
{template.scenes.map(scene => (
<span
key={scene.id}
className={`text-[7px] px-1 py-0.5 rounded uppercase tracking-wider ${
scene.type === 'intro' || scene.type === 'outro'
? 'bg-amber-500/10 text-amber-500'
: 'bg-neutral-800 text-neutral-500'
}`}
>
{scene.name}
</span>
))}
</div>
</div>
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-16 text-neutral-600">
<Filter size={32} className="mx-auto mb-3 opacity-40" />
<p className="text-sm">No hay plantillas para estos filtros</p>
</div>
)}
</div>
);
};
+190
View File
@@ -0,0 +1,190 @@
import React, { useRef, useState, useCallback, RefObject } from 'react';
import { Play, Pause, RotateCcw, Volume2 } from 'lucide-react';
import { PlayerRef } from '@remotion/player';
import { TimelineElement } from '../../types';
import { TimelineRuler } from '../timeline/TimelineRuler';
import { TimelinePlayhead } from '../timeline/TimelinePlayhead';
interface ExpressTimelineProps {
playerRef: RefObject<PlayerRef | null>;
elements: TimelineElement[];
durationInFrames: number;
selectedSlotId: string | null;
onSelectSlot: (slotId: string) => void;
isPlaying: boolean;
onPlayToggle: () => void;
onSeek: (frame: number) => void;
duration: number;
}
/** Color + icon mapping for each element type */
const SLOT_COLORS: Record<string, { bg: string; border: string; text: string }> = {
text: { bg: 'bg-violet-500/20', border: 'border-violet-500/40', text: 'text-violet-300' },
image: { bg: 'bg-sky-500/20', border: 'border-sky-500/40', text: 'text-sky-300' },
video: { bg: 'bg-sky-500/20', border: 'border-sky-500/40', text: 'text-sky-300' },
audio: { bg: 'bg-emerald-500/20', border: 'border-emerald-500/40', text: 'text-emerald-300' },
sticker: { bg: 'bg-amber-500/20', border: 'border-amber-500/40', text: 'text-amber-300' },
shape: { bg: 'bg-fuchsia-500/20', border: 'border-fuchsia-500/40', text: 'text-fuchsia-300' },
};
const SLOT_SELECTED: Record<string, { bg: string; border: string }> = {
text: { bg: 'bg-violet-500/35', border: 'border-violet-500/70' },
image: { bg: 'bg-sky-500/35', border: 'border-sky-500/70' },
video: { bg: 'bg-sky-500/35', border: 'border-sky-500/70' },
audio: { bg: 'bg-emerald-500/35', border: 'border-emerald-500/70' },
sticker: { bg: 'bg-amber-500/35', border: 'border-amber-500/70' },
shape: { bg: 'bg-fuchsia-500/35', border: 'border-fuchsia-500/70' },
};
/**
* ExpressTimeline — Simplified timeline for Express editor.
* Reuses TimelineRuler and TimelinePlayhead from the Pro editor.
* Shows element bars as simple colored blocks, no resize/reorder.
*/
export const ExpressTimeline: React.FC<ExpressTimelineProps> = ({
playerRef,
elements,
durationInFrames,
selectedSlotId,
onSelectSlot,
isPlaying,
onPlayToggle,
onSeek,
duration,
}) => {
const timelineRef = useRef<HTMLDivElement>(null);
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);
const seekFromPointer = useCallback((clientX: number) => {
if (!timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
const ratio = x / rect.width;
const frame = Math.round(ratio * durationInFrames);
onSeek(frame);
}, [durationInFrames, onSeek]);
const handleRulerPointerDown = useCallback((e: React.PointerEvent) => {
seekFromPointer(e.clientX);
}, [seekFromPointer]);
const handleRulerPointerMove = useCallback((e: React.PointerEvent) => {
if (e.buttons === 1) seekFromPointer(e.clientX);
}, [seekFromPointer]);
const handlePlayheadPointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
setIsDraggingPlayhead(true);
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
}, []);
const handlePlayheadPointerMove = useCallback((e: React.PointerEvent) => {
if (!isDraggingPlayhead) return;
seekFromPointer(e.clientX);
}, [isDraggingPlayhead, seekFromPointer]);
const handlePlayheadPointerUp = useCallback(() => {
setIsDraggingPlayhead(false);
}, []);
// Filter out brand elements (intro/outro) from display — they show as amber accent
const contentElements = elements.filter(el => !el.isBrandElement);
const brandElements = elements.filter(el => el.isBrandElement);
return (
<div className="bg-neutral-900/80 border-t border-neutral-800/60 shrink-0">
{/* ── Mini Controls ── */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-neutral-800/40">
<button
onClick={onPlayToggle}
title={isPlaying ? 'Pausar' : 'Reproducir'}
className="w-6 h-6 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-sm"
>
{isPlaying ? <Pause size={10} fill="currentColor" /> : <Play size={10} fill="currentColor" />}
</button>
<button
onClick={() => onSeek(0)}
title="Reiniciar"
className="w-5 h-5 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
>
<RotateCcw size={9} />
</button>
<span className="text-[9px] text-neutral-500 font-mono">{duration}s · {durationInFrames}f</span>
<div className="flex-1" />
{elements.some(el => el.type === 'audio') && (
<Volume2 size={12} className="text-emerald-400 opacity-60" />
)}
</div>
{/* ── Ruler + Tracks ── */}
<div ref={timelineRef} className="relative">
{/* Ruler (REUSED) */}
<TimelineRuler
timeUnit="seconds"
durationInFrames={durationInFrames}
onPointerDown={handleRulerPointerDown}
onPointerMove={handleRulerPointerMove}
onPointerUp={() => {}}
/>
{/* Track bars */}
<div className="relative px-1 py-1 space-y-0.5 min-h-[40px]">
{/* Brand elements (intro/outro) — subtle amber */}
{brandElements.map(el => {
const left = (el.startFrame / durationInFrames) * 100;
const width = ((el.endFrame - el.startFrame) / durationInFrames) * 100;
return (
<div
key={el.id}
className="h-5 rounded-md bg-amber-500/10 border border-amber-500/20 flex items-center px-1.5 overflow-hidden"
style={{ marginLeft: `${left}%`, width: `${width}%` }}
title={el.startFrame === 0 ? 'Intro de marca' : 'Outro de marca'}
>
<span className="text-[7px] text-amber-400 font-medium truncate">
{el.startFrame === 0 ? '🎬 Intro' : '🎬 Outro'}
</span>
</div>
);
})}
{/* Content elements — colored by type */}
{contentElements.map(el => {
const left = (el.startFrame / durationInFrames) * 100;
const width = Math.max(2, ((el.endFrame - el.startFrame) / durationInFrames) * 100);
const colors = SLOT_COLORS[el.type] || SLOT_COLORS.text;
const isSelected = selectedSlotId === el.id;
const selectedColors = SLOT_SELECTED[el.type] || SLOT_SELECTED.text;
return (
<button
key={el.id}
onClick={() => onSelectSlot(el.id)}
title={el.elementName || el.content?.substring(0, 30) || el.type}
className={`h-5 rounded-md border flex items-center px-1.5 overflow-hidden cursor-pointer transition-all hover:brightness-125 ${
isSelected
? `${selectedColors.bg} ${selectedColors.border} ring-1 ring-white/10`
: `${colors.bg} ${colors.border}`
}`}
style={{ marginLeft: `${left}%`, width: `${width}%` }}
>
<span className={`text-[7px] font-medium truncate ${colors.text}`}>
{el.elementName || el.content?.substring(0, 25) || el.type}
</span>
</button>
);
})}
</div>
{/* Playhead (REUSED) */}
<TimelinePlayhead
playerRef={playerRef}
durationInFrames={durationInFrames}
onPointerDown={handlePlayheadPointerDown}
onPointerMove={handlePlayheadPointerMove}
onPointerUp={handlePlayheadPointerUp}
isDraggingPlayhead={isDraggingPlayhead}
/>
</div>
</div>
);
};
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
import { Film, Image as ImageIcon, Play, Type, Camera, ChevronRight } from 'lucide-react';
import { ExpressScene } from '../../types';
interface SceneCardProps {
scene: ExpressScene;
index: number;
isActive: boolean;
onClick: () => void;
/** Whether any field has user content */
hasContent: boolean;
/** Total scenes count */
totalScenes: number;
}
/** Type badge styles */
const TYPE_STYLES: Record<string, { bg: string; border: string; icon: React.ReactNode; label: string }> = {
intro: { bg: 'bg-amber-500/15', border: 'border-amber-500/40', icon: <Film size={10} />, label: 'INTRO' },
content: { bg: 'bg-violet-500/15', border: 'border-violet-500/40', icon: <Camera size={10} />, label: 'CONTENIDO' },
outro: { bg: 'bg-amber-500/15', border: 'border-amber-500/40', icon: <Film size={10} />, label: 'OUTRO' },
transition: { bg: 'bg-sky-500/15', border: 'border-sky-500/40', icon: <Play size={10} />, label: 'TRANSICIÓN' },
};
/**
* SceneCard — Visual card representing a single scene in the storyboard.
* Shows scene type, name, duration, and field summary.
*/
export const SceneCard: React.FC<SceneCardProps> = ({
scene,
index,
isActive,
onClick,
hasContent,
totalScenes,
}) => {
const typeStyle = TYPE_STYLES[scene.type] || TYPE_STYLES.content;
const textFields = scene.editableFields.filter(f => f.type === 'text');
const mediaFields = scene.editableFields.filter(f => f.type === 'media');
const logoFields = scene.editableFields.filter(f => f.type === 'logo');
return (
<div className="flex items-center shrink-0">
<button
onClick={onClick}
title={`${scene.name}${scene.durationSeconds}s`}
className={`relative w-28 h-24 rounded-xl border-2 transition-all overflow-hidden cursor-pointer group shrink-0 ${
isActive
? `${typeStyle.bg} ${typeStyle.border} ring-2 ring-white/10 shadow-lg`
: `bg-neutral-900 border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800/50`
}`}
>
{/* Type badge */}
<div className={`absolute top-1.5 left-1.5 flex items-center gap-0.5 px-1 py-0.5 rounded text-[7px] font-bold tracking-wider ${
isActive ? 'text-white bg-black/30' : 'text-neutral-500 bg-neutral-800'
}`}>
{typeStyle.icon}
{typeStyle.label}
</div>
{/* Duration */}
<div className="absolute top-1.5 right-1.5 text-[8px] font-mono text-neutral-500">
{scene.durationSeconds}s
</div>
{/* Scene name */}
<div className="absolute bottom-1.5 left-1.5 right-1.5">
<div className={`text-[10px] font-semibold truncate ${isActive ? 'text-white' : 'text-neutral-400'}`}>
{scene.name}
</div>
{/* Field summary */}
<div className="flex items-center gap-1.5 mt-0.5">
{textFields.length > 0 && (
<span className="flex items-center gap-0.5 text-[7px] text-neutral-500">
<Type size={7} /> {textFields.length}
</span>
)}
{mediaFields.length > 0 && (
<span className="flex items-center gap-0.5 text-[7px] text-neutral-500">
<ImageIcon size={7} /> {mediaFields.length}
</span>
)}
{logoFields.length > 0 && (
<span className="flex items-center gap-0.5 text-[7px] text-neutral-500">
{logoFields.length}
</span>
)}
</div>
</div>
{/* Content indicator */}
{hasContent && (
<div className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-emerald-400" />
)}
{/* Scene number */}
<div className={`absolute inset-0 flex items-center justify-center text-3xl font-black transition-opacity ${
isActive ? 'opacity-10 text-white' : 'opacity-5 text-neutral-400'
}`}>
{index + 1}
</div>
</button>
{/* Arrow connector (except last scene) */}
{index < totalScenes - 1 && (
<ChevronRight size={14} className="text-neutral-700 mx-1 shrink-0" />
)}
</div>
);
};
+220
View File
@@ -0,0 +1,220 @@
import React from 'react';
import { Type, Image as ImageIcon, Upload, Zap, Clock, Layers } from 'lucide-react';
import { ExpressScene, DesignMD, SceneLayout, TemplateField } from '../../types';
interface SceneFieldEditorProps {
scene: ExpressScene;
fieldData: Record<string, string>;
onFieldChange: (fieldId: string, value: string) => void;
designMD: DesignMD;
}
/** Layout display names */
const LAYOUT_LABELS: Record<SceneLayout, string> = {
'fullscreen-media': '📸 Pantalla completa',
'media-left': '◧ Media izquierda',
'media-right': '◨ Media derecha',
'text-only': '📝 Solo texto',
'split': '◫ Dividido',
'overlay': '🔲 Overlay',
};
/**
* SceneFieldEditor — Right panel showing editable fields for the active scene.
* User fills in text and media — no video editor needed.
*
* Supports both new TemplateField[] format (scene.fields) and legacy ExpressField[] (scene.editableFields).
* When using new format, only shows editable-slot fields sorted by formOrder.
*/
export const SceneFieldEditor: React.FC<SceneFieldEditorProps> = ({
scene,
fieldData,
onFieldChange,
designMD,
}) => {
// Prefer new TemplateField[] format; filter to only editable-slots, sort by formOrder
const useNewFormat = scene.fields && scene.fields.length > 0;
let textFields: Array<{ id: string; label: string; required: boolean; brandSource?: string; placeholder: string; style: { fontSize?: number; fontWeight?: number } }>;
let mediaFields: Array<{ id: string; label: string; required: boolean; placeholder: string; rules?: TemplateField['rules'] }>;
let logoFields: Array<{ id: string; label: string; brandSource?: string }>;
if (useNewFormat) {
const editableSlots = scene.fields!
.filter(f => f.nature === 'editable-slot')
.sort((a, b) => a.formOrder - b.formOrder);
textFields = editableSlots
.filter(f => f.type === 'text')
.map(f => ({ id: f.id, label: f.label, required: f.required, placeholder: f.content || f.label, style: f.style }));
mediaFields = editableSlots
.filter(f => f.type === 'image' || f.type === 'video')
.map(f => ({ id: f.id, label: f.label, required: f.required, placeholder: f.content || f.label, rules: f.rules }));
// Brand variables shown as read-only logo/info fields
logoFields = scene.fields!
.filter(f => f.nature === 'brand-variable')
.map(f => ({ id: f.id, label: f.label, brandSource: f.brandSource }));
} else {
textFields = scene.editableFields
.filter(f => f.type === 'text')
.map(f => ({ id: f.id, label: f.label, required: f.required, brandSource: f.brandSource, placeholder: f.placeholder || f.label, style: f.style }));
mediaFields = scene.editableFields
.filter(f => f.type === 'media')
.map(f => ({ id: f.id, label: f.label, required: f.required, placeholder: f.placeholder || f.label }));
logoFields = scene.editableFields
.filter(f => f.type === 'logo')
.map(f => ({ id: f.id, label: f.label, brandSource: f.brandSource }));
}
return (
<div className="space-y-4">
{/* Scene header */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
scene.type === 'intro' || scene.type === 'outro' ? 'bg-amber-500' : 'bg-violet-500'
}`} />
<span className="text-sm font-semibold text-white">{scene.name}</span>
</div>
<div className="flex items-center gap-3 text-[9px] text-neutral-500">
<span className="flex items-center gap-1">
<Clock size={9} /> {scene.durationSeconds}s
</span>
<span className="flex items-center gap-1">
<Layers size={9} /> {LAYOUT_LABELS[scene.layout]}
</span>
</div>
</div>
{/* Text fields */}
{textFields.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Type size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Textos</span>
</div>
{textFields.map(field => {
const isBrandVar = !!field.brandSource;
return (
<div key={field.id} className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[10px] text-neutral-400 font-medium">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{isBrandVar && (
<span className="flex items-center gap-0.5 text-[7px] text-violet-400 bg-violet-500/10 px-1 py-0.5 rounded">
<Zap size={7} /> auto
</span>
)}
</div>
{field.style.fontSize && field.style.fontSize >= 28 ? (
<input
type="text"
value={fieldData[field.id] || ''}
onChange={(e) => onFieldChange(field.id, e.target.value)}
placeholder={field.placeholder.replace(/\{[^}]+\}/g, designMD.brandName || '')}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-white placeholder-neutral-600 focus:border-violet-500/50 focus:outline-none transition-colors"
style={{
fontFamily: designMD.titleFont || designMD.baseFont,
fontWeight: field.style.fontWeight || 700,
}}
/>
) : (
<textarea
value={fieldData[field.id] || ''}
onChange={(e) => onFieldChange(field.id, e.target.value)}
placeholder={field.placeholder.replace(/\{[^}]+\}/g, designMD.brandName || '')}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:border-violet-500/50 focus:outline-none transition-colors resize-none"
style={{
fontFamily: designMD.paragraphFont || designMD.baseFont,
}}
/>
)}
</div>
);
})}
</div>
)}
{/* Media fields */}
{mediaFields.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<ImageIcon size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Media</span>
</div>
{mediaFields.map(field => (
<div key={field.id} className="space-y-1">
<label className="text-[10px] text-neutral-400 font-medium">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{fieldData[field.id] ? (
<div className="relative group">
<img
src={fieldData[field.id]}
alt={field.label}
className="w-full h-24 object-cover rounded-lg border border-neutral-700"
/>
<button
onClick={() => onFieldChange(field.id, '')}
title="Quitar media"
className="absolute top-1 right-1 bg-black/60 text-white text-[8px] px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center h-20 bg-neutral-800/50 border-2 border-dashed border-neutral-700 rounded-lg cursor-pointer hover:border-neutral-600 hover:bg-neutral-800 transition-all">
<Upload size={16} className="text-neutral-500 mb-1" />
<span className="text-[9px] text-neutral-500">{field.placeholder}</span>
<input
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
onFieldChange(field.id, url);
}
}}
/>
</label>
)}
</div>
))}
</div>
)}
{/* Logo fields (auto from brand) */}
{logoFields.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Zap size={10} className="text-violet-400" />
<span className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold">Marca</span>
</div>
{logoFields.map(field => (
<div key={field.id} className="flex items-center gap-2 bg-violet-500/5 border border-violet-500/20 rounded-lg px-3 py-2">
{designMD.logoUrl ? (
<img src={designMD.logoUrl} alt="Logo" className="w-8 h-8 object-contain" />
) : (
<div className="w-8 h-8 bg-neutral-800 rounded flex items-center justify-center text-[8px] text-neutral-500">Logo</div>
)}
<div>
<span className="text-[10px] text-violet-300">{field.label}</span>
<span className="text-[8px] text-violet-400/60 block">Auto desde tu marca</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import { Clock } from 'lucide-react';
import { ExpressScene } from '../../types';
import { SceneCard } from './SceneCard';
interface StoryboardViewProps {
scenes: ExpressScene[];
activeSceneId: string | null;
onSelectScene: (sceneId: string) => void;
fieldData: Record<string, string>;
totalDuration: number;
}
/**
* StoryboardView — Horizontal strip of scene cards.
* This IS the "timeline" for Express — no video editor needed.
* User clicks a scene to edit its fields in the right panel.
*/
export const StoryboardView: React.FC<StoryboardViewProps> = ({
scenes,
activeSceneId,
onSelectScene,
fieldData,
totalDuration,
}) => {
return (
<div className="bg-neutral-900/80 border-t border-neutral-800/60 shrink-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-neutral-800/40">
<div className="flex items-center gap-2">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Escenas</span>
<span className="text-[9px] text-neutral-600 font-mono bg-neutral-800 px-1.5 py-0.5 rounded">
{scenes.length} escenas
</span>
</div>
<div className="flex items-center gap-1.5">
<Clock size={10} className="text-neutral-600" />
<span className="text-[9px] text-neutral-500 font-mono">{totalDuration}s total</span>
</div>
</div>
{/* Scene cards strip */}
<div className="flex items-center overflow-x-auto px-4 py-3 gap-0 scrollbar-thin scrollbar-track-neutral-900 scrollbar-thumb-neutral-700">
{scenes.map((scene, i) => {
const hasContent = scene.editableFields.some(
f => fieldData[f.id]?.trim()
);
return (
<SceneCard
key={scene.id}
scene={scene}
index={i}
isActive={activeSceneId === scene.id}
onClick={() => onSelectScene(scene.id)}
hasContent={hasContent}
totalScenes={scenes.length}
/>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,473 @@
import React, { useRef, useCallback, useMemo } from 'react';
import {
Type, Image as ImageIcon, Video, Pentagon, Zap, Move, Maximize2,
Upload, Film,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, DesignMD, CompanyProfile, StickerConfig } from '../../../types';
import { getAspectDimensions } from '../../../utils/expressCompiler';
import { useDragResize } from '../../../hooks/useDragResize';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { getPlatformIcon, isSocialSource, DEFAULT_STICKER } from './PlatformIcons';
import { resolveBrandRole } from '../../ui/FieldInspector';
import { SegmentVideoFrame } from './SegmentVideoFrame';
/** Get type icon */
function getTypeIcon(type: TemplateField['type'], size = 14): React.ReactNode {
switch (type) {
case 'text': return <Type size={size} />;
case 'image': return <ImageIcon size={size} />;
case 'video': return <Video size={size} />;
case 'shape': return <Pentagon size={size} />;
case 'sticker': return <Zap size={size} />;
}
}
/** Nature colors */
const NATURE_COLORS: Record<TemplateFieldNature, { border: string; bg: string; text: string; badge: string }> = {
'static': {
border: 'rgba(107, 114, 128, 0.4)',
bg: 'rgba(107, 114, 128, 0.08)',
text: '#9ca3af',
badge: '#6b7280',
},
'brand-variable': {
border: 'rgba(167, 139, 250, 0.5)',
bg: 'rgba(167, 139, 250, 0.08)',
text: '#c4b5fd',
badge: '#a78bfa',
},
'editable-slot': {
border: 'rgba(56, 189, 248, 0.5)',
bg: 'rgba(56, 189, 248, 0.06)',
text: '#7dd3fc',
badge: '#38bdf8',
},
};
/** Resolve brand variable to preview text */
function resolveBrandPreview(field: TemplateField, designMD: DesignMD, company: CompanyProfile): string {
if (field.nature !== 'brand-variable' || !field.brandSource) return field.content;
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || 'Tu Marca';
case 'tagline': return company.tagline || 'Tu eslogan';
case 'logo': return designMD.logoUrl || '';
case 'instagram': return company.socialLinks?.instagram || '@instagram';
case 'tiktok': return company.socialLinks?.tiktok || '@tiktok';
case 'twitter': return company.socialLinks?.x || '@x';
case 'youtube': return company.socialLinks?.youtube || 'YouTube';
case 'website': return company.socialLinks?.website || 'www.example.com';
default: return field.content;
}
}
/**
* BuilderCanvas — Interactive canvas for the Template Builder.
*
* Renders TemplateField[] directly with visual differentiation by nature:
* - static: solid border, rendered content, no badge
* - brand-variable: dotted violet border, real preview data, "auto" badge
* - editable-slot: dashed blue border, placeholder zone with icon + label, "campo" badge
*
* Uses the shared `useDragResize` hook for all pointer interactions (per AGENTS.md).
*/
export const BuilderCanvas: React.FC = () => {
const {
fields,
updateField,
selectedFieldId,
setSelectedFieldId,
designMD,
company,
templateMeta,
activeScene,
updateSegment,
previewBrand,
} = useTemplateBuilder();
// Detect segment mode: active scene is an intro/outro with segmentSource
const isSegmentMode = !!(activeScene?.segmentSource);
const containerRef = useRef<HTMLDivElement>(null);
// ── Shared drag/resize hook ──
const {
startDrag,
startResize,
handlePointerMove,
handlePointerUp,
isDragging,
activeId: dragFieldId,
snapGuides,
} = useDragResize({
containerRef: containerRef as React.RefObject<HTMLElement>,
onMove: useCallback((id: string, x: number, y: number) => {
const field = fields.find(f => f.id === id);
if (!field) return;
updateField(id, { position: { ...field.position, x, y } });
}, [fields, updateField]),
onResize: useCallback((id: string, w: number, h: number) => {
const field = fields.find(f => f.id === id);
if (!field) return;
updateField(id, { position: { ...field.position, w, h } });
}, [fields, updateField]),
snapLines: [50],
snapThreshold: 1.5,
});
const dimensions = getAspectDimensions(templateMeta.aspectRatio);
// Resolve background
const bgColor = useMemo(() => {
const bg = activeScene?.background;
if (!bg) return designMD.secondaryColor;
switch (bg.type) {
case 'brand': return designMD.secondaryColor;
case 'solid': return bg.value || '#1a1a1a';
case 'gradient': return undefined;
case 'media': return '#111';
default: return designMD.secondaryColor;
}
}, [activeScene, designMD]);
const bgGradient = activeScene?.background?.type === 'gradient'
? `linear-gradient(135deg, ${designMD.primaryColor} 0%, ${designMD.secondaryColor} 100%)`
: undefined;
const handleCanvasClick = useCallback(() => {
if (!isDragging) setSelectedFieldId(null);
}, [isDragging, setSelectedFieldId]);
// In segment mode, render SegmentVideoFrame instead of normal fields
if (isSegmentMode && activeScene) {
return (
<SegmentVideoFrame
scene={activeScene}
designMD={designMD}
previewBrand={previewBrand}
aspectRatio={templateMeta.aspectRatio}
onPositionChange={(updates) => updateSegment(activeScene.id, updates)}
/>
);
}
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Dot pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Canvas wrapper */}
<div
ref={containerRef}
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 select-none shrink-0"
style={{
...(templateMeta.aspectRatio === '9:16' || templateMeta.aspectRatio === '4:5'
? { height: 'calc(100% - 40px)', maxWidth: '90%' }
: {
width: templateMeta.aspectRatio === '1:1' ? 360 : 440,
maxHeight: 'calc(100% - 40px)',
}),
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
backgroundColor: bgColor,
backgroundImage: bgGradient,
}}
onPointerDown={handleCanvasClick}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Snap guides */}
{snapGuides.x !== undefined && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
style={{ left: `${snapGuides.x}%`, width: '1px', background: 'rgba(139, 92, 246, 0.5)', borderLeft: '1px dashed rgba(139, 92, 246, 0.6)' }}
/>
)}
{snapGuides.y !== undefined && (
<div
className="absolute left-0 right-0 pointer-events-none z-50"
style={{ top: `${snapGuides.y}%`, height: '1px', background: 'rgba(139, 92, 246, 0.5)', borderTop: '1px dashed rgba(139, 92, 246, 0.6)' }}
/>
)}
{/* Center crosshair (subtle) */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-white/[0.03] pointer-events-none z-0" />
<div className="absolute top-1/2 left-0 right-0 h-px bg-white/[0.03] pointer-events-none z-0" />
{/* ── Render fields ── */}
{fields.map((field, idx) => {
// Skip hidden layers
if (field.visible === false) return null;
const isSelected = selectedFieldId === field.id;
const isDraggingField = dragFieldId === field.id;
const isLocked = field.locked === true;
const colors = NATURE_COLORS[field.nature];
return (
<div
key={field.id}
className="absolute transition-shadow"
style={{
left: `${field.position.x - field.position.w / 2}%`,
top: `${field.position.y - field.position.h / 2}%`,
width: `${field.position.w}%`,
height: `${field.position.h}%`,
transform: field.position.rotation ? `rotate(${field.position.rotation}deg)` : undefined,
// z-index from array position: index 0 = back, last = front
// Dragging/selected get temporary boost to stay on top during interaction
zIndex: isDraggingField ? 1000 : isSelected ? 999 : idx + 1,
}}
>
{/* Field box */}
<div
className={`w-full h-full rounded-md flex flex-col items-center justify-center gap-0.5 transition-all ${
isLocked ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'
} ${isDraggingField ? 'scale-[1.02] shadow-xl' : ''}`}
style={{
backgroundColor: isSelected ? `${colors.badge}20` : colors.bg,
border: `${field.nature === 'editable-slot' ? '2px dashed' : field.nature === 'brand-variable' ? '1px dotted' : '1px solid'} ${
isSelected ? colors.badge : colors.border
}`,
outline: isSelected ? `2px solid ${colors.badge}60` : undefined,
outlineOffset: isSelected ? '2px' : undefined,
}}
onPointerDown={(e) => {
e.stopPropagation();
if (isLocked) return; // Can't interact with locked layers
setSelectedFieldId(field.id);
startDrag(e, field.id, field.position);
}}
>
{/* ── Nature-specific content ── */}
{field.nature === 'static' && (
<StaticFieldContent field={field} designMD={designMD} />
)}
{field.nature === 'brand-variable' && (
<BrandVariableContent field={field} designMD={designMD} company={company} />
)}
{field.nature === 'editable-slot' && (
<EditableSlotContent field={field} />
)}
{/* ── Badge (brand-variable and editable-slot) ── */}
{field.nature !== 'static' && (
<div
className="absolute -top-2.5 left-2 flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[7px] font-bold tracking-wider pointer-events-none"
style={{
backgroundColor: `${colors.badge}20`,
color: colors.badge,
border: `1px solid ${colors.badge}40`,
}}
>
{field.nature === 'brand-variable' ? (
<><Zap size={7} /> auto</>
) : (
<>{getTypeIcon(field.type, 7)} {field.label}</>
)}
</div>
)}
{/* Position readout when selected */}
{isSelected && (
<div className="absolute -bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1 text-[7px] text-violet-300/60 font-mono whitespace-nowrap pointer-events-none">
<Move size={7} /> {field.position.x.toFixed(0)},{field.position.y.toFixed(0)}
<Maximize2 size={7} className="ml-1" /> {field.position.w.toFixed(0)}×{field.position.h.toFixed(0)}
</div>
)}
</div>
{/* Resize handle */}
{isSelected && (
<div
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 border-2 border-neutral-900 rounded-sm cursor-nwse-resize z-40 hover:opacity-80 transition-colors"
style={{ backgroundColor: colors.badge }}
onPointerDown={(e) => startResize(e, field.id, field.position)}
title="Redimensionar campo"
/>
)}
</div>
);
})}
{/* Empty state */}
{fields.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-[10px] text-white/20 text-center">
Agrega campos desde el panel izquierdo<br />
para posicionarlos aquí
</p>
</div>
)}
</div>
</div>
);
};
// ═══ Nature-specific content renderers ═══
/** Static: show actual content/shape */
const StaticFieldContent: React.FC<{ field: TemplateField; designMD: DesignMD }> = ({ field, designMD }) => {
if (field.type === 'text') {
// Resolve brand typography if useBrandStyle is active
const brandStyle = (field.style.useBrandStyle !== false && field.style.textRole)
? resolveBrandRole(designMD, field.style.textRole)
: null;
return (
<span
className="pointer-events-none text-center px-1 truncate w-full"
style={{
fontSize: `${Math.min(brandStyle?.fontSize || field.style.fontSize || 16, 20)}px`,
fontWeight: brandStyle?.fontWeight || field.style.fontWeight || 400,
fontFamily: brandStyle?.fontFamily || field.style.fontFamily || designMD.baseFont,
color: brandStyle?.color || field.style.color || designMD.textColor || '#ffffff',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{field.content || 'Texto estático'}
</span>
);
}
if (field.type === 'shape') {
return (
<div
className="w-full h-full rounded pointer-events-none"
style={{
backgroundColor: field.style.shapeFill || designMD.primaryColor,
borderRadius: field.style.shapeCornerRadius ? `${field.style.shapeCornerRadius}px` : undefined,
opacity: (field.style.opacity ?? 100) / 100,
}}
/>
);
}
// image or video static
return (
<div className="flex flex-col items-center justify-center gap-0.5 pointer-events-none" style={{ color: '#6b7280' }}>
{getTypeIcon(field.type, 16)}
<span className="text-[7px] font-mono">{field.type}</span>
</div>
);
};
/** Brand variable: show real preview with brand styling */
const BrandVariableContent: React.FC<{ field: TemplateField; designMD: DesignMD; company: CompanyProfile }> = ({ field, designMD, company }) => {
const preview = resolveBrandPreview(field, designMD, company);
// Logo: show image
if (field.brandSource === 'logo' && designMD.logoUrl) {
return (
<img
src={designMD.logoUrl}
alt="Logo"
className="max-w-full max-h-full object-contain pointer-events-none p-1"
style={{ opacity: (field.style.opacity ?? 100) / 100 }}
/>
);
}
// Sticker: icon + text composite
if (field.type === 'sticker') {
return <BrandStickerContent field={field} designMD={designMD} company={company} />;
}
// Text brand variable: show with brand font
const brandStyle = (field.style.useBrandStyle !== false && field.style.textRole)
? resolveBrandRole(designMD, field.style.textRole)
: null;
return (
<span
className="pointer-events-none text-center px-1 truncate w-full"
style={{
fontSize: `${Math.min(brandStyle?.fontSize || field.style.fontSize || 16, 18)}px`,
fontWeight: brandStyle?.fontWeight || field.style.fontWeight || 400,
fontFamily: brandStyle?.fontFamily || field.style.fontFamily || designMD.baseFont,
color: brandStyle?.color || field.style.color || '#c4b5fd',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{preview}
</span>
);
};
/** Brand Sticker: icon + text as a single visual unit */
const BrandStickerContent: React.FC<{ field: TemplateField; designMD: DesignMD; company: CompanyProfile }> = ({ field, designMD, company }) => {
const sticker: StickerConfig = field.style.sticker || DEFAULT_STICKER;
const rawValue = resolveBrandPreview(field, designMD, company);
// Format display text
const displayText = sticker.showAtPrefix && isSocialSource(field.brandSource) && field.brandSource !== 'website'
? `@${rawValue.replace(/^@/, '')}`
: field.brandSource === 'website'
? rawValue.replace(/^https?:\/\//, '').replace(/\/$/, '')
: rawValue;
const iconSize = Math.min(Math.max((field.style.fontSize || 14) * 0.9, 10), 18);
const icon = sticker.showIcon ? getPlatformIcon(field.brandSource, iconSize) : null;
const isPill = sticker.stickerStyle === 'pill';
return (
<div
className={`flex items-center pointer-events-none w-full h-full justify-center ${
isPill ? 'px-2' : 'px-1'
}`}
>
<div
className={`flex items-center ${
isPill ? 'bg-white/10 rounded-full px-3 py-1' : ''
}`}
style={{
gap: `${sticker.gap}px`,
flexDirection: sticker.iconPosition === 'right' ? 'row-reverse' : 'row',
}}
>
{icon && (
<span
className="shrink-0 flex items-center justify-center"
style={{ color: sticker.iconColor || designMD.primaryColor }}
>
{icon}
</span>
)}
<span
className="truncate"
style={{
fontSize: `${Math.min(field.style.fontSize || 14, 16)}px`,
fontWeight: field.style.fontWeight || 500,
fontFamily: field.style.fontFamily || designMD.baseFont,
color: field.style.color || '#c4b5fd',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{displayText}
</span>
</div>
</div>
);
};
/** Editable slot: show placeholder zone */
const EditableSlotContent: React.FC<{ field: TemplateField }> = ({ field }) => {
return (
<div className="flex flex-col items-center justify-center gap-1 pointer-events-none w-full h-full">
<div style={{ color: '#7dd3fc' }}>
{field.type === 'text' && <Type size={16} />}
{field.type === 'image' && <Upload size={16} />}
{field.type === 'video' && <Film size={16} />}
{field.type === 'shape' && <Pentagon size={16} />}
</div>
<span className="text-[8px] text-sky-300/60 font-medium truncate max-w-[90%] text-center">
{field.label}
</span>
{field.required && (
<span className="text-[6px] text-red-400/60 font-bold">OBLIGATORIO</span>
)}
</div>
);
};
@@ -0,0 +1,321 @@
import React from 'react';
import { X, Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD, TimelineElement } from '../../../types';
import { useEditor } from '../../../context/EditorContext';
import { CollapsibleSection } from '../../ui/CollapsibleSection';
interface BuilderScenePanelProps {
onClose: () => void;
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
}
/** Layout options */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: ExpressField['type'] }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* BuilderScenePanel — Sliding panel for scene-specific configuration.
*
* This is the template-builder counterpart of TextPanel/ShapesPanel.
* It manages scene metadata (name, type, duration, background, layout)
* and lets users add brand variables and brand assets as TimelineElements.
*/
export const BuilderScenePanel: React.FC<BuilderScenePanelProps> = ({
onClose,
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
}) => {
const {
setTimelineElements,
setSelectedElementId,
layers,
activeLayerId,
durationInFrames,
} = useEditor();
// ── Add a brand-variable element to the canvas via EditorContext ──
const addBrandField = (
type: ExpressField['type'],
label: string,
brandSource?: ExpressField['brandSource'],
) => {
const newId = 'el-' + Date.now();
const elType: TimelineElement['type'] = type === 'text' ? 'text' : 'image';
// Determine target layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: brandSource ? `{${brandSource}}` : label,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: type === 'text' ? 80 : 40,
height: type === 'text' ? 10 : 20,
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
elementName: label,
notes: JSON.stringify({
__expressField: true,
brandSource,
required: false,
fieldType: type,
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
// ── Add a brand content asset ──
const addBrandAsset = (asset: BrandContentPiece) => {
const newId = 'el-asset-' + Date.now();
const elType: TimelineElement['type'] = asset.type === 'custom-image' ? 'image' : 'text';
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: asset.content.text || asset.name,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: 40,
height: 20,
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
elementName: asset.name,
notes: JSON.stringify({
__expressField: true,
brandAssetId: asset.id,
required: false,
fieldType: asset.type === 'custom-image' ? 'media' : 'text',
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Layers size={14} className="text-amber-400" />
Escena
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4 custom-scrollbar">
{/* Scene name */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Quick-add field buttons */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">
Agregar campos
</label>
<div className="flex gap-1">
<button
onClick={() => addBrandField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addBrandField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addBrandField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* ── Diseño y Fondo (collapsible) ── */}
<CollapsibleSection title="Diseño y Fondo">
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-2 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨' : bg === 'solid' ? '⬛' : bg === 'gradient' ? '🌈' : '📷'}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
)}
</CollapsibleSection>
</div>
</div>
);
};
@@ -0,0 +1,475 @@
import React from 'react';
import {
Settings2, Tag, ToggleLeft, Type, Image as ImageIcon, Video, Pentagon,
Zap, AlertCircle, Hash, Eye, EyeOff, ArrowLeftRight,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, TemplateFieldType, BrandSource, StickerConfig } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { FieldInspector } from '../../ui/FieldInspector';
import { CollapsibleSection } from '../../ui/CollapsibleSection';
import { DEFAULT_STICKER, getPlatformIcon } from './PlatformIcons';
/** Nature display config */
const NATURE_CONFIG: Record<TemplateFieldNature, { label: string; color: string; icon: React.ReactNode }> = {
'static': { label: 'Estático', color: '#6b7280', icon: <Pentagon size={10} /> },
'brand-variable': { label: 'Variable de marca', color: '#a78bfa', icon: <Zap size={10} /> },
'editable-slot': { label: 'Campo editable', color: '#38bdf8', icon: <Tag size={10} /> },
};
/** Type options */
const TYPE_OPTIONS: { value: TemplateFieldType; label: string; icon: React.ReactNode }[] = [
{ value: 'text', label: 'Texto', icon: <Type size={10} /> },
{ value: 'image', label: 'Imagen', icon: <ImageIcon size={10} /> },
{ value: 'video', label: 'Video', icon: <Video size={10} /> },
{ value: 'shape', label: 'Forma', icon: <Pentagon size={10} /> },
{ value: 'sticker', label: 'Sticker', icon: <Zap size={10} /> },
];
/** Brand sources */
const BRAND_SOURCES: { value: BrandSource; label: string }[] = [
{ value: 'brand-name', label: 'Nombre de Marca' },
{ value: 'tagline', label: 'Tagline' },
{ value: 'logo', label: 'Logo' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'tiktok', label: 'TikTok' },
{ value: 'twitter', label: 'X / Twitter' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'website', label: 'Website' },
];
/**
* FieldConfigPanel — Right panel in the Template Builder.
*
* Shows properties for the selected field, adapted by its nature.
* Reuses FieldInspector for position editing and CollapsibleSection for grouping.
*/
export const FieldConfigPanel: React.FC = () => {
const {
fields,
selectedFieldId,
setSelectedFieldId,
updateField,
resolvedDesignMD,
editableSlotCount,
totalFieldCount,
templateMeta,
} = useTemplateBuilder();
const field = fields.find(f => f.id === selectedFieldId);
// No selection — show hint
if (!field) {
return (
<div className="flex flex-col items-center justify-center h-full px-6 text-center py-8">
<p className="text-[11px] text-neutral-500 leading-relaxed">
Selecciona un campo en el canvas o en la lista para configurarlo.
</p>
</div>
);
}
const natureConfig = NATURE_CONFIG[field.nature];
const brandColors = [resolvedDesignMD.primaryColor, resolvedDesignMD.secondaryColor, resolvedDesignMD.textColor].filter(Boolean);
return (
<div>
{/* Header */}
<div className="p-3 border-b border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-neutral-400" />
<span className="text-sm font-semibold text-white truncate max-w-[140px]">{field.label}</span>
</div>
<button
onClick={() => setSelectedFieldId(null)}
title="Deseleccionar"
className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors text-xs"
>
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
style={{ color: natureConfig.color, backgroundColor: `${natureConfig.color}15`, border: `1px solid ${natureConfig.color}30` }}
>
{natureConfig.icon} {natureConfig.label}
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar">
{/* ── Label ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Etiqueta</label>
<input
type="text"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Nombre del campo"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white placeholder-neutral-600 focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* ── Nature selector ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Naturaleza</label>
<div className="flex gap-1">
{(['static', 'brand-variable', 'editable-slot'] as TemplateFieldNature[]).map(nature => {
const cfg = NATURE_CONFIG[nature];
const isActive = field.nature === nature;
return (
<button
key={nature}
onClick={() => updateField(field.id, { nature })}
title={cfg.label}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg text-[8px] font-medium transition-all border ${
isActive
? 'text-white'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
style={isActive ? {
backgroundColor: `${cfg.color}15`,
borderColor: `${cfg.color}40`,
color: cfg.color,
} : {}}
>
{cfg.icon} {nature === 'editable-slot' ? 'Campo' : nature === 'brand-variable' ? 'Auto' : 'Fijo'}
</button>
);
})}
</div>
</div>
{/* ── Type selector ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<div className="flex gap-1">
{TYPE_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => updateField(field.id, { type: opt.value })}
title={opt.label}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
field.type === opt.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{opt.icon} {opt.label}
</button>
))}
</div>
</div>
{/* ── Required toggle (editable-slot only) ── */}
{field.nature === 'editable-slot' && (
<div className="flex items-center justify-between">
<label className="text-[10px] text-neutral-400 flex items-center gap-1.5">
<AlertCircle size={10} />
Obligatorio
</label>
<button
onClick={() => updateField(field.id, { required: !field.required })}
title={field.required ? 'Marcar como opcional' : 'Marcar como obligatorio'}
className={`flex items-center gap-1 px-2 py-1 rounded text-[9px] font-medium transition-all ${
field.required
? 'bg-red-500/15 text-red-400 border border-red-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700 hover:text-neutral-300'
}`}
>
<ToggleLeft size={10} />
{field.required ? 'Sí' : 'No'}
</button>
</div>
)}
{/* ── Brand source (brand-variable only) ── */}
{field.nature === 'brand-variable' && (
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Fuente de datos
</label>
<select
value={field.brandSource || ''}
onChange={(e) => updateField(field.id, { brandSource: e.target.value as BrandSource })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="">Seleccionar...</option>
{BRAND_SOURCES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
)}
{/* ── Sticker config (sticker type only) ── */}
{field.type === 'sticker' && (() => {
const sticker: StickerConfig = field.style.sticker || DEFAULT_STICKER;
const updateSticker = (patch: Partial<StickerConfig>) => {
updateField(field.id, {
style: { ...field.style, sticker: { ...sticker, ...patch } },
});
};
return (
<CollapsibleSection title="Sticker" badge={1} defaultOpen={true}>
<div className="space-y-3">
{/* Show icon */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400 flex items-center gap-1">
{sticker.showIcon ? <Eye size={10} /> : <EyeOff size={10} />}
Mostrar ícono
</label>
<button
onClick={() => updateSticker({ showIcon: !sticker.showIcon })}
title={sticker.showIcon ? 'Ocultar ícono' : 'Mostrar ícono'}
className={`flex items-center gap-1 px-2 py-1 rounded text-[9px] font-medium transition-all ${
sticker.showIcon
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{sticker.showIcon ? 'Sí' : 'No'}
</button>
</div>
{/* Icon position */}
{sticker.showIcon && (
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400 flex items-center gap-1">
<ArrowLeftRight size={10} />
Posición ícono
</label>
<div className="flex gap-1">
{(['left', 'right'] as const).map(pos => (
<button
key={pos}
onClick={() => updateSticker({ iconPosition: pos })}
title={pos === 'left' ? 'Ícono a la izquierda' : 'Ícono a la derecha'}
className={`px-2 py-1 rounded text-[8px] font-medium transition-all border ${
sticker.iconPosition === pos
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{pos === 'left' ? '← Izq' : 'Der →'}
</button>
))}
</div>
</div>
)}
{/* @ prefix */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Prefijo @</label>
<button
onClick={() => updateSticker({ showAtPrefix: !sticker.showAtPrefix })}
title={sticker.showAtPrefix ? 'Ocultar @' : 'Mostrar @'}
className={`px-2 py-1 rounded text-[9px] font-medium transition-all ${
sticker.showAtPrefix
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{sticker.showAtPrefix ? '@usuario' : 'usuario'}
</button>
</div>
{/* Style: plain or pill */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Estilo</label>
<div className="flex gap-1">
{(['plain', 'pill'] as const).map(style => (
<button
key={style}
onClick={() => updateSticker({ stickerStyle: style })}
title={style === 'plain' ? 'Texto plano' : 'Pill con fondo'}
className={`px-2 py-1 rounded text-[8px] font-medium transition-all border ${
sticker.stickerStyle === style
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{style === 'plain' ? 'Plano' : 'Pill'}
</button>
))}
</div>
</div>
{/* Gap */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Gap (px)</label>
<span className="text-[9px] text-neutral-500 font-mono">{sticker.gap}px</span>
</div>
<input
type="range"
min={0}
max={16}
value={sticker.gap}
onChange={(e) => updateSticker({ gap: parseInt(e.target.value) })}
className="w-full accent-violet-500 h-1"
/>
</div>
{/* Icon color */}
{sticker.showIcon && (
<div className="space-y-1">
<label className="text-[9px] text-neutral-400">Color ícono</label>
<div className="flex items-center gap-2">
<input
type="color"
value={sticker.iconColor || resolvedDesignMD.primaryColor}
onChange={(e) => updateSticker({ iconColor: e.target.value })}
className="w-6 h-6 rounded border border-neutral-700 cursor-pointer bg-transparent"
/>
<span className="text-[9px] text-neutral-500 font-mono">
{sticker.iconColor || resolvedDesignMD.primaryColor}
</span>
{sticker.iconColor && (
<button
onClick={() => updateSticker({ iconColor: undefined })}
title="Usar color de marca"
className="text-[8px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Reset
</button>
)}
</div>
{/* Preview */}
<div className="flex items-center gap-2 mt-1 px-2 py-1.5 bg-neutral-800/60 rounded-lg border border-neutral-700/50">
<span style={{ color: sticker.iconColor || resolvedDesignMD.primaryColor }}>
{getPlatformIcon(field.brandSource, 14)}
</span>
<span className="text-[10px] text-neutral-300">Vista previa</span>
</div>
</div>
)}
</div>
</CollapsibleSection>
);
})()}
<hr className="border-neutral-800/50" />
{/* ── Position (FieldInspector) ── */}
<FieldInspector
position={field.position}
onPositionChange={(pos) => {
updateField(field.id, {
position: { ...field.position, ...pos },
});
}}
textStyle={field.type === 'text' ? {
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
fontFamily: field.style.fontFamily,
color: field.style.color,
textAlign: field.style.textAlign,
opacity: field.style.opacity,
useBrandStyle: field.style.useBrandStyle,
textRole: field.style.textRole,
} : undefined}
onTextStyleChange={field.type === 'text' ? (style) => {
updateField(field.id, {
style: { ...field.style, ...style },
});
} : undefined}
fieldType={field.type === 'video' ? 'media' : field.type === 'shape' ? 'text' : field.type}
fieldLabel={field.label}
brandFont={resolvedDesignMD.baseFont?.split(',')[0]?.replace(/"/g, '')}
brandColors={brandColors}
resolvedDesignMD={resolvedDesignMD}
/>
{/* ── Rules (editable-slot only) ── */}
{field.nature === 'editable-slot' && (
<CollapsibleSection title="Reglas de validación" defaultOpen={false}>
<div className="space-y-2">
{/* Text rules */}
{field.type === 'text' && (
<>
<div className="space-y-1">
<label className="text-[9px] text-neutral-500">Máx. caracteres</label>
<input
type="number"
min={0}
value={field.rules?.maxChars || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, maxChars: parseInt(e.target.value) || undefined },
})}
placeholder="Sin límite"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Multilínea</label>
<button
onClick={() => updateField(field.id, {
rules: { ...field.rules, multiline: !field.rules?.multiline },
})}
title="Alternar multilínea"
className={`text-[9px] px-2 py-1 rounded transition-all ${
field.rules?.multiline
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{field.rules?.multiline ? 'Sí' : 'No'}
</button>
</div>
</>
)}
{/* Image/Video rules */}
{(field.type === 'image' || field.type === 'video') && (
<>
<div className="space-y-1">
<label className="text-[9px] text-neutral-500">Aspect ratio</label>
<input
type="text"
value={field.rules?.aspectRatio || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, aspectRatio: e.target.value || undefined },
})}
placeholder="ej. 16:9"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500">Ancho mín.</label>
<input
type="number"
min={0}
value={field.rules?.minWidth || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, minWidth: parseInt(e.target.value) || undefined },
})}
placeholder="px"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500">Alto mín.</label>
<input
type="number"
min={0}
value={field.rules?.minHeight || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, minHeight: parseInt(e.target.value) || undefined },
})}
placeholder="px"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
</div>
</>
)}
</div>
</CollapsibleSection>
)}
</div>
</div>
);
};
@@ -0,0 +1,435 @@
import React, { useCallback, useState } from 'react';
import {
Plus, Type, Image as ImageIcon, Video, Pentagon, Zap,
Trash2, GripVertical, Sparkles, Globe, Instagram, AtSign, Star, Layers,
Eye, EyeOff, Lock, Unlock,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, BrandSource, StickerConfig } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { DEFAULT_STICKER } from './PlatformIcons';
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: BrandSource; label: string; icon: React.ReactNode; fieldType: 'text' | 'image' | 'sticker' }[] = [
{ source: 'brand-name', label: 'Nombre', icon: <Type size={10} />, fieldType: 'text' },
{ source: 'tagline', label: 'Tagline', icon: <Sparkles size={10} />, fieldType: 'text' },
{ source: 'logo', label: 'Logo', icon: <Star size={10} />, fieldType: 'image' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, fieldType: 'sticker' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, fieldType: 'sticker' },
];
/** Type icon mapping */
function getTypeIcon(type: TemplateField['type'], size = 10): React.ReactNode {
switch (type) {
case 'text': return <Type size={size} />;
case 'image': return <ImageIcon size={size} />;
case 'video': return <Video size={size} />;
case 'shape': return <Pentagon size={size} />;
case 'sticker': return <Zap size={size} />;
}
}
/** Nature badge config */
const NATURE_BADGE: Record<TemplateFieldNature, { label: string; color: string; bg: string; border: string }> = {
'static': { label: 'fijo', color: '#9ca3af', bg: 'rgba(107,114,128,0.10)', border: 'rgba(107,114,128,0.25)' },
'brand-variable': { label: 'auto', color: '#c4b5fd', bg: 'rgba(167,139,250,0.10)', border: 'rgba(167,139,250,0.25)' },
'editable-slot': { label: 'campo', color: '#7dd3fc', bg: 'rgba(56,189,248,0.10)', border: 'rgba(56,189,248,0.25)' },
};
/**
* FieldSchemaPanel — Layers panel (Photoshop/Figma style) for the Template Builder.
*
* Photoshop convention: first row = front (highest z-index), last row = back.
* Internally, the fields array is ordered bottom-to-top (index 0 = back, last = front).
* The panel renders the list REVERSED so the topmost layer appears first.
*
* Each layer row shows: visibility toggle, lock toggle, type icon, editable name,
* nature badge, and optional req badge.
*
* Bottom section: quick-add buttons for new fields and brand variables.
*/
export const FieldSchemaPanel: React.FC<{ onClose?: () => void }> = ({ onClose }) => {
const {
fields,
addField,
removeField,
reorderField,
moveField,
updateField,
selectedFieldId,
setSelectedFieldId,
editableSlotCount,
totalFieldCount,
} = useTemplateBuilder();
// Reversed for Photoshop convention: front layers on top
const layersReversed = [...fields].reverse();
// ── Drag & Drop state ──
const [dragId, setDragId] = useState<string | null>(null);
const [dropDisplayIdx, setDropDisplayIdx] = useState<number | null>(null);
const handleDragStart = useCallback((fieldId: string) => {
setDragId(fieldId);
}, []);
const handleDragOver = useCallback((displayIdx: number) => {
setDropDisplayIdx(displayIdx);
}, []);
const handleDrop = useCallback(() => {
if (dragId && dropDisplayIdx !== null) {
// Convert display index (reversed) → array index
// Display 0 = array last, Display N = array first
const totalLen = fields.length;
const targetArrayIdx = totalLen - 1 - dropDisplayIdx;
moveField(dragId, Math.max(0, Math.min(targetArrayIdx, totalLen - 1)));
}
setDragId(null);
setDropDisplayIdx(null);
}, [dragId, dropDisplayIdx, fields.length, moveField]);
const handleDragEnd = useCallback(() => {
setDragId(null);
setDropDisplayIdx(null);
}, []);
// ── Add handlers ──
const handleAddEditableSlot = useCallback((type: TemplateField['type'], label: string) => {
const newId = addField({
nature: 'editable-slot',
type,
label,
required: false,
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
const handleAddBrandVariable = useCallback((source: BrandSource, label: string, type: 'text' | 'image' | 'sticker') => {
const stickerDefaults: Partial<StickerConfig> | undefined = type === 'sticker'
? { ...DEFAULT_STICKER, showAtPrefix: source !== 'website' }
: undefined;
const newId = addField({
nature: 'brand-variable',
type,
label,
brandSource: source,
content: `{${source}}`,
...(stickerDefaults ? { style: { sticker: stickerDefaults as StickerConfig } } : {}),
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
const handleAddStatic = useCallback((type: TemplateField['type'], label: string) => {
const newId = addField({
nature: 'static',
type,
label,
content: label,
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg">
{/* ── Header ── */}
<div className="p-3 border-b border-neutral-800 flex items-center justify-between shrink-0">
<div>
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Layers size={14} className="text-violet-400" />
Capas
</h3>
<p className="text-[9px] text-neutral-500 mt-0.5 font-mono">
{totalFieldCount} capa{totalFieldCount !== 1 ? 's' : ''} · {editableSlotCount} campo{editableSlotCount !== 1 ? 's' : ''}
</p>
</div>
{onClose && (
<button onClick={onClose} title="Cerrar panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors text-xs">
</button>
)}
</div>
{/* ── Layers list (full height, scrollable) ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="p-2 space-y-0">
{layersReversed.length === 0 ? (
<p className="text-[9px] text-neutral-600 text-center py-6 italic">
Sin capas. Agrega elementos abajo.
</p>
) : (
layersReversed.map((field, displayIdx) => (
<React.Fragment key={field.id}>
{/* Drop indicator line */}
{dropDisplayIdx === displayIdx && dragId !== field.id && (
<div className="h-0.5 bg-violet-500 rounded-full mx-1 my-0.5 shadow-sm shadow-violet-500/50" />
)}
<LayerRow
field={field}
isSelected={selectedFieldId === field.id}
isDragging={dragId === field.id}
onSelect={() => setSelectedFieldId(field.id)}
onRemove={() => removeField(field.id)}
onToggleVisible={() => updateField(field.id, { visible: field.visible === false ? true : false })}
onToggleLocked={() => updateField(field.id, { locked: !field.locked })}
onRename={(name) => updateField(field.id, { label: name })}
onDragStart={() => handleDragStart(field.id)}
onDragOver={() => handleDragOver(displayIdx)}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
</React.Fragment>
))
)}
{/* Drop indicator at very bottom */}
{dropDisplayIdx === layersReversed.length && (
<div className="h-0.5 bg-violet-500 rounded-full mx-1 my-0.5 shadow-sm shadow-violet-500/50" />
)}
</div>
<hr className="border-neutral-800/50 mx-3" />
{/* ═══ Add Fields ═══ */}
<div className="p-3 space-y-3">
{/* Editable slots */}
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">
Agregar campo editable
</span>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => handleAddEditableSlot('text', 'Texto')}
title="Agregar campo de texto editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => handleAddEditableSlot('image', 'Imagen')}
title="Agregar campo de imagen editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Imagen
</button>
<button
onClick={() => handleAddEditableSlot('video', 'Video')}
title="Agregar campo de video editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Video
</button>
<button
onClick={() => handleAddStatic('shape', 'Forma')}
title="Agregar elemento estático (forma)"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-neutral-600 hover:text-neutral-400 hover:bg-neutral-800/50 transition-all"
>
<Plus size={8} /> Forma
</button>
</div>
</div>
{/* Brand variables */}
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de marca
</span>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => handleAddBrandVariable(v.source, v.label, v.fieldType)}
title={`Insertar variable {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
};
// ═══════════════════════════════════════════════════════════════
// LayerRow — Individual layer in Photoshop-style list with DnD
// ═══════════════════════════════════════════════════════════════
interface LayerRowProps {
field: TemplateField;
isSelected: boolean;
isDragging: boolean;
onSelect: () => void;
onRemove: () => void;
onToggleVisible: () => void;
onToggleLocked: () => void;
onRename: (name: string) => void;
onDragStart: () => void;
onDragOver: () => void;
onDrop: () => void;
onDragEnd: () => void;
}
const LayerRow: React.FC<LayerRowProps> = ({
field,
isSelected,
isDragging,
onSelect,
onRemove,
onToggleVisible,
onToggleLocked,
onRename,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(field.label);
const isVisible = field.visible !== false;
const isLocked = field.locked === true;
const isBg = field.isBackground === true;
const canRename = !isBg && field.nature !== 'brand-variable';
const canDrag = !isBg;
const badge = NATURE_BADGE[field.nature];
const handleDoubleClick = useCallback(() => {
if (!canRename) return;
setRenameValue(field.label);
setIsRenaming(true);
}, [field.label, canRename]);
const commitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== field.label) {
onRename(trimmed);
}
setIsRenaming(false);
}, [renameValue, field.label, onRename]);
const handleRenameKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') { setRenameValue(field.label); setIsRenaming(false); }
}, [commitRename, field.label]);
return (
<div
onClick={onSelect}
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
onDrop={(e) => { e.preventDefault(); onDrop(); }}
className={`flex items-center gap-1 rounded-md px-1.5 py-1 cursor-pointer transition-all group ${
isSelected
? 'bg-violet-500/10 border border-violet-500/40 ring-1 ring-violet-500/20'
: 'bg-transparent border border-transparent hover:bg-neutral-800/60 hover:border-neutral-700/50'
} ${!isVisible ? 'opacity-40' : ''} ${isDragging ? 'opacity-30' : ''}`}
>
{/* Drag handle */}
{canDrag ? (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', field.id);
onDragStart();
}}
onDragEnd={onDragEnd}
className="cursor-grab active:cursor-grabbing text-neutral-600 hover:text-neutral-400 shrink-0 p-0.5"
title="Arrastrar para reordenar"
>
<GripVertical size={10} />
</div>
) : (
<div className="text-neutral-800 shrink-0 p-0.5" title="Capa de fondo (fija)">
<GripVertical size={10} />
</div>
)}
{/* Visibility toggle */}
<button
onClick={(e) => { e.stopPropagation(); onToggleVisible(); }}
title={isVisible ? 'Ocultar capa' : 'Mostrar capa'}
className="text-neutral-500 hover:text-white transition-colors p-0.5 shrink-0"
>
{isVisible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
{/* Lock toggle */}
<button
onClick={(e) => { e.stopPropagation(); onToggleLocked(); }}
title={isLocked ? 'Desbloquear capa' : 'Bloquear capa'}
className={`transition-colors p-0.5 shrink-0 ${
isLocked ? 'text-amber-400 hover:text-amber-300' : 'text-neutral-600 hover:text-neutral-400'
}`}
>
{isLocked ? <Lock size={10} /> : <Unlock size={10} />}
</button>
{/* Type icon */}
<span style={{ color: badge.color }} className="shrink-0">
{getTypeIcon(field.type)}
</span>
{/* Name (inline editable on double click) */}
{isRenaming && canRename ? (
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
autoFocus
className="flex-1 bg-neutral-800 border border-violet-500/50 rounded px-1 py-0.5 text-[10px] text-white focus:outline-none focus:ring-1 focus:ring-violet-500/40 min-w-0"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 text-[10px] text-neutral-300 truncate select-none"
onDoubleClick={canRename ? (e) => { e.stopPropagation(); handleDoubleClick(); } : undefined}
title={canRename ? 'Doble clic para renombrar' : 'Nombre heredado de la marca'}
>
{field.label}
</span>
)}
{/* Badge: FONDO for background, or nature badge */}
{isBg ? (
<span
className="text-[7px] px-1 py-0.5 rounded shrink-0 font-bold uppercase tracking-wider"
style={{ color: '#fbbf24', backgroundColor: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.30)' }}
>
fondo
</span>
) : (
<>
<span
className="text-[7px] px-1 py-0.5 rounded shrink-0 font-bold uppercase tracking-wider"
style={{ color: badge.color, backgroundColor: badge.bg, border: `1px solid ${badge.border}` }}
>
{badge.label}
</span>
{field.nature === 'editable-slot' && field.required && (
<span className="text-[7px] text-red-400 bg-red-500/10 px-1 py-0.5 rounded shrink-0 font-bold border border-red-500/20">
req
</span>
)}
</>
)}
{/* Delete (hover only, not for background) */}
{!isBg && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
title="Eliminar capa"
className="text-neutral-600 hover:text-red-400 transition-colors p-px opacity-0 group-hover:opacity-100 shrink-0"
>
<Trash2 size={9} />
</button>
)}
</div>
);
};
@@ -0,0 +1,196 @@
import React from 'react';
import {
Zap, FileText, Hash, Film,
} from 'lucide-react';
import { TemplateField, DesignMD, CompanyProfile, ExpressScene } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { TemplateFieldInput } from '../../shared/TemplateFieldInput';
/** Resolve brand variable preview text */
function resolveBrandPreview(field: TemplateField, designMD: DesignMD, company: CompanyProfile): string {
if (!field.brandSource) return '';
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || 'Tu Marca';
case 'tagline': return company.tagline || '';
case 'logo': return '(Logo de marca)';
case 'instagram': return company.socialLinks?.instagram || '';
case 'tiktok': return company.socialLinks?.tiktok || '';
case 'twitter': return company.socialLinks?.x || '';
case 'youtube': return company.socialLinks?.youtube || '';
case 'website': return company.socialLinks?.website || '';
default: return '';
}
}
/**
* FormPreviewPanel — Preview of the auto-generated form that the end-user will see in Express.
*
* Shows only editable-slot fields in their formOrder, rendered as the appropriate input type.
* Brand variables appear as read-only info rows (not editable).
* This is the "Vista de formulario" toggle in the builder.
*
* Uses the shared TemplateFieldInput component in disabled mode.
*/
export const FormPreviewPanel: React.FC = () => {
const {
fields,
designMD,
company,
templateMeta,
editableSlotCount,
scenes,
} = useTemplateBuilder();
// Detect segments
const formSegments = scenes.filter(
(s: ExpressScene) => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'form'
);
const brandSegments = scenes.filter(
(s: ExpressScene) => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'brand'
);
const editableSlots = fields
.filter(f => f.nature === 'editable-slot')
.sort((a, b) => a.formOrder - b.formOrder);
const brandVars = fields.filter(f => f.nature === 'brand-variable');
return (
<div className="flex-1 flex items-start justify-center bg-neutral-950 p-6 overflow-auto min-h-0">
{/* Form card */}
<div className="w-full max-w-md bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-neutral-800 bg-gradient-to-r from-sky-500/5 to-violet-500/5">
<div className="flex items-center gap-2 mb-2">
<FileText size={16} className="text-sky-400" />
<h2 className="text-sm font-bold text-white">Vista previa del formulario</h2>
</div>
<p className="text-[10px] text-neutral-500">
Este es el formulario que verá quien produzca contenido con esta plantilla.
</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-[9px] text-sky-400 bg-sky-500/10 px-2 py-0.5 rounded-full font-medium">
<Hash size={8} className="inline mr-0.5" />
{editableSlotCount} campo{editableSlotCount !== 1 ? 's' : ''}
</span>
<span className="text-[9px] text-neutral-500">{templateMeta.name || 'Plantilla'}</span>
</div>
</div>
{/* Form fields */}
<div className="p-6 space-y-5">
{/* ── Form-sourced segment fields (video upload previews) ── */}
{formSegments.length > 0 && (
<div className="space-y-3 pb-4 border-b border-neutral-800/50 mb-4">
<div className="flex items-center gap-2">
<Film size={12} className="text-emerald-400" />
<span className="text-[10px] font-bold text-white uppercase tracking-wider">Segmentos de video</span>
</div>
{formSegments.map((scene: ExpressScene) => {
const isIntro = scene.type === 'intro';
const syntheticField: TemplateField = {
id: `segment-${scene.id}`,
nature: 'editable-slot',
type: 'video',
label: scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre'),
required: scene.segmentFieldRequired ?? true,
content: isIntro ? 'Video de intro' : 'Video de cierre',
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: isIntro ? -2 : 999,
};
return (
<TemplateFieldInput
key={syntheticField.id}
field={syntheticField}
value=""
onChange={() => {}}
designMD={designMD}
disabled
/>
);
})}
</div>
)}
{editableSlots.length === 0 && formSegments.length === 0 ? (
<div className="text-center py-8">
<Hash size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">No hay campos editables.</p>
<p className="text-[10px] text-neutral-600 mt-1">
Agrega campos desde el panel "Campos" para que aparezcan aquí.
</p>
</div>
) : editableSlots.length === 0 ? null : (
editableSlots.map((field) => (
<TemplateFieldInput
key={field.id}
field={field}
value=""
onChange={() => {}}
designMD={designMD}
disabled
/>
))
)}
{/* Brand-sourced segments (read-only info) */}
{brandSegments.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-emerald-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Segmentos automáticos
</p>
<div className="space-y-2">
{brandSegments.map((scene: ExpressScene) => (
<div
key={scene.id}
className="flex items-center gap-3 px-3 py-2.5 bg-emerald-500/5 border border-emerald-500/15 rounded-lg"
>
<Film size={10} className="text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-emerald-300 font-medium">{scene.name}</span>
<span className="text-[9px] text-emerald-400/50 block">
{scene.durationSeconds}s desde la marca
</span>
</div>
<span className="text-[7px] text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
{/* Brand variables (read-only info) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde la marca
</p>
<div className="space-y-2">
{brandVars.map(field => (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{resolveBrandPreview(field, designMD, company)}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,68 @@
import React from 'react';
import { Globe } from 'lucide-react';
import { BrandSource } from '../../../types';
/**
* PlatformIcons — Inline SVG icons for social platforms.
* Used by BrandStickerContent to render the icon portion of a sticker.
*/
/** Instagram icon */
const InstagramIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
/** TikTok icon */
const TikTokIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1V9.01a6.27 6.27 0 00-.79-.05 6.34 6.34 0 00-6.34 6.34 6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.33-6.34V9.19a8.16 8.16 0 004.77 1.53V7.27a4.84 4.84 0 01-1-.58z" />
</svg>
);
/** X (Twitter) icon */
const XIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
/** YouTube icon */
const YouTubeIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
/** Website / Globe icon — reuses Lucide Globe */
const WebsiteIcon: React.FC<{ size: number }> = ({ size }) => (
<Globe size={size} />
);
/** Get the platform icon component for a given brand source */
export function getPlatformIcon(source: BrandSource | string | undefined, size: number): React.ReactNode {
switch (source) {
case 'instagram': return <InstagramIcon size={size} />;
case 'tiktok': return <TikTokIcon size={size} />;
case 'twitter': return <XIcon size={size} />;
case 'youtube': return <YouTubeIcon size={size} />;
case 'website': return <WebsiteIcon size={size} />;
default: return null;
}
}
/** Brand sources that produce stickers (icon + text) */
export const SOCIAL_SOURCES: string[] = ['instagram', 'tiktok', 'twitter', 'youtube', 'website'];
/** Check if a brand source should produce a sticker */
export const isSocialSource = (source?: string): boolean => SOCIAL_SOURCES.includes(source || '');
/** Default sticker configuration */
export const DEFAULT_STICKER = {
showIcon: true,
iconPosition: 'left' as const,
showAtPrefix: true,
stickerStyle: 'plain' as const,
gap: 6,
};
@@ -0,0 +1,306 @@
import React from 'react';
import { Film, Plus, X, ArrowRight, Volume2, Music, Camera } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
import { SegmentCard } from './SegmentCard';
interface SceneComposerProps {
scenes: ExpressScene[];
activeSceneId: string | null;
onSelectScene: (sceneId: string) => void;
onAddScene: () => void;
onRemoveScene: (sceneId: string) => void;
designMD: DesignMD;
usesBrandAudio: boolean;
format: 'video' | 'image';
// Segment management
onAddSegment: (position: 'before' | 'after', source: 'brand' | 'form') => void;
onRemoveSegment: (position: 'before' | 'after') => void;
onUpdateSegment: (sceneId: string, updates: Partial<ExpressScene>) => void;
previewBrand: CompanyProfile | null;
}
/** Color mapping for scene types */
const TYPE_COLORS: Record<string, string> = {
intro: '#10b981',
content: '#8b5cf6',
outro: '#f43f5e',
transition: '#3b82f6',
};
const TYPE_ICONS: Record<string, React.ReactNode> = {
intro: <Film size={12} />,
content: <Camera size={12} />,
outro: <Film size={12} />,
transition: <ArrowRight size={12} />,
};
/**
* SceneComposer — Visual block composition for video templates.
*
* Layout: [Intro segment?] → [Content scene blocks + Add] → [Outro segment?]
* Below track: [+ Antes] button (if no intro) | [+ Después] button (if no outro)
*/
export const SceneComposer: React.FC<SceneComposerProps> = ({
scenes,
activeSceneId,
onSelectScene,
onAddScene,
onRemoveScene,
designMD,
usesBrandAudio,
format,
onAddSegment,
onRemoveSegment,
onUpdateSegment,
previewBrand,
}) => {
// Separate segments from content scenes
const introScene = scenes.find(s => s.type === 'intro') || null;
const outroScene = scenes.find(s => s.type === 'outro') || null;
const contentScenes = scenes.filter(s => s.type === 'content' || s.type === 'transition');
const totalDur = scenes.reduce((sum, s) => sum + s.durationSeconds, 0);
const contentDur = contentScenes.reduce((sum, s) => sum + s.durationSeconds, 0);
const hasAudio = usesBrandAudio && !!designMD.brandAudioUrl;
return (
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Film size={14} className="text-neutral-500" />
<h4 className="text-[10px] font-mono uppercase tracking-widest text-neutral-500">
Composición de Escenas
</h4>
</div>
<span className="text-[10px] font-mono text-neutral-600">
{totalDur.toFixed(1)}s · {scenes.length} escena{scenes.length !== 1 ? 's' : ''}
</span>
</div>
{/* Video Track */}
<div className="px-4 pt-3 pb-1">
<div className="flex items-center gap-0.5 text-[8px] text-neutral-500 mb-1.5 font-mono uppercase tracking-widest">
<Film size={9} /> {format === 'video' ? 'Video' : 'Imagen'}
</div>
<div className="flex items-stretch gap-2 min-h-[48px]">
{/* ── Intro Segment ── */}
{introScene && (
<>
<div
onClick={() => onSelectScene(introScene.id)}
className={`cursor-pointer rounded-xl transition-all ${activeSceneId === introScene.id ? 'ring-2 ring-emerald-500/60 ring-offset-1 ring-offset-neutral-950' : 'hover:ring-1 hover:ring-neutral-600/50'}`}
>
<SegmentCard
scene={introScene}
position="before"
designMD={designMD}
previewBrand={previewBrand}
onSourceChange={(source) => onUpdateSegment(introScene.id, {
segmentSource: source,
name: source === 'brand' ? 'Intro de marca' : 'Video de intro',
segmentFieldLabel: source === 'form' ? 'Video de intro' : undefined,
segmentFieldRequired: source === 'form' ? true : undefined,
})}
onDurationChange={(seconds) => onUpdateSegment(introScene.id, { durationSeconds: seconds })}
onLabelChange={(label) => onUpdateSegment(introScene.id, { segmentFieldLabel: label })}
onRequiredChange={(required) => onUpdateSegment(introScene.id, { segmentFieldRequired: required })}
onTransitionChange={(type) => onUpdateSegment(introScene.id, {
segmentTransition: { type, duration: introScene.segmentTransition?.duration || 10 },
})}
onRemove={() => onRemoveSegment('before')}
/>
</div>
{/* Arrow between intro and content */}
<div className="flex flex-col items-center justify-center shrink-0 px-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
</>
)}
{/* ── Content block: fixed "Contenido" badge ── */}
<div className="flex items-center gap-1 flex-1 min-w-0">
{contentScenes.map((scene, i) => {
const color = TYPE_COLORS[scene.type] || TYPE_COLORS.content;
const isActive = activeSceneId === scene.id;
const widthPct = contentDur > 0 ? (scene.durationSeconds / contentDur) * 100 : 25;
const canRemove = contentScenes.length > 1;
return (
<React.Fragment key={scene.id}>
<button
onClick={() => onSelectScene(scene.id)}
title={`${scene.name}${scene.durationSeconds}s · Click para editar`}
className={`h-12 rounded-lg flex flex-col items-center justify-center text-center transition-all relative overflow-hidden group cursor-pointer ${
isActive
? 'ring-2 ring-offset-1 ring-offset-neutral-900 scale-[1.02] z-10'
: 'hover:scale-[1.01]'
}`}
style={{
flex: `${Math.max(widthPct, 12)} 0 0`,
minWidth: '80px',
backgroundColor: isActive ? `${color}30` : `${color}15`,
border: `1px solid ${isActive ? color : `${color}40`}`,
['--tw-ring-color' as string]: color,
}}
>
{/* Shimmer on active */}
{isActive && (
<div
className="absolute inset-0 opacity-20"
style={{
background: `linear-gradient(90deg, transparent, ${color}40, transparent)`,
animation: 'shimmer 2s infinite',
}}
/>
)}
<span style={{ color }} className="mb-0.5 opacity-80 relative z-10">
{TYPE_ICONS[scene.type]}
</span>
<span className="text-[8px] font-bold tracking-wider text-white/80 relative z-10">
{scene.name.toUpperCase()}
</span>
{format === 'video' && (
<span className="text-[8px] font-mono text-neutral-400 relative z-10">
{scene.durationSeconds}s
</span>
)}
{/* Remove button */}
{canRemove && isActive && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveScene(scene.id); }}
title="Eliminar escena"
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full flex items-center justify-center text-[8px] hover:bg-red-400 transition-colors z-20"
>
<X size={8} />
</button>
)}
</button>
{/* Transition dot between content scenes */}
{i < contentScenes.length - 1 && (
<div className="flex flex-col items-center shrink-0 px-0.5 gap-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
)}
</React.Fragment>
);
})}
{/* Add content scene button */}
<button
onClick={onAddScene}
title="Agregar escena de contenido"
className="h-12 min-w-[40px] rounded-lg border-2 border-dashed border-neutral-700 flex items-center justify-center text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 hover:bg-violet-500/5 transition-all cursor-pointer shrink-0"
>
<Plus size={14} />
</button>
</div>
{/* ── Outro Segment ── */}
{outroScene && (
<>
{/* Arrow between content and outro */}
<div className="flex flex-col items-center justify-center shrink-0 px-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
<div
onClick={() => onSelectScene(outroScene.id)}
className={`cursor-pointer rounded-xl transition-all ${activeSceneId === outroScene.id ? 'ring-2 ring-emerald-500/60 ring-offset-1 ring-offset-neutral-950' : 'hover:ring-1 hover:ring-neutral-600/50'}`}
>
<SegmentCard
scene={outroScene}
position="after"
designMD={designMD}
previewBrand={previewBrand}
onSourceChange={(source) => onUpdateSegment(outroScene.id, {
segmentSource: source,
name: source === 'brand' ? 'Outro de marca' : 'Video de cierre',
segmentFieldLabel: source === 'form' ? 'Video de cierre' : undefined,
segmentFieldRequired: source === 'form' ? true : undefined,
})}
onDurationChange={(seconds) => onUpdateSegment(outroScene.id, { durationSeconds: seconds })}
onLabelChange={(label) => onUpdateSegment(outroScene.id, { segmentFieldLabel: label })}
onRequiredChange={(required) => onUpdateSegment(outroScene.id, { segmentFieldRequired: required })}
onTransitionChange={(type) => onUpdateSegment(outroScene.id, {
segmentTransition: { type, duration: outroScene.segmentTransition?.duration || 10 },
})}
onRemove={() => onRemoveSegment('after')}
/>
</div>
</>
)}
</div>
{/* ── Add segment buttons (below track) ── */}
{format === 'video' && (!introScene || !outroScene) && (
<div className="flex items-center gap-2 mt-2">
{!introScene && (
<button
onClick={() => onAddSegment('before', 'brand')}
title="Agregar contenido antes (intro)"
className="flex-1 h-8 rounded-lg border border-dashed border-neutral-700 flex items-center justify-center gap-1.5 text-neutral-500 hover:border-emerald-500/50 hover:text-emerald-400 hover:bg-emerald-500/5 transition-all cursor-pointer text-[9px] font-medium"
>
<Plus size={10} /> Antes
</button>
)}
{!outroScene && (
<button
onClick={() => onAddSegment('after', 'brand')}
title="Agregar contenido después (outro)"
className="flex-1 h-8 rounded-lg border border-dashed border-neutral-700 flex items-center justify-center gap-1.5 text-neutral-500 hover:border-rose-500/50 hover:text-rose-400 hover:bg-rose-500/5 transition-all cursor-pointer text-[9px] font-medium"
>
<Plus size={10} /> Después
</button>
)}
</div>
)}
</div>
{/* Audio Track (only for video) */}
{format === 'video' && (
<div className="px-4 pb-3 pt-1">
<div className="flex items-center gap-0.5 text-[8px] text-neutral-500 mb-1.5 font-mono uppercase tracking-widest">
<Volume2 size={9} /> Audio
</div>
<div
className={`w-full h-7 rounded-lg border flex items-center gap-2 px-3 ${
hasAudio
? 'border-neutral-800 bg-neutral-950'
: 'border-neutral-800 bg-transparent'
}`}
>
{hasAudio ? (
<>
<div className="flex items-end gap-[1px] h-4 flex-1">
{Array.from({ length: 48 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-full bg-neutral-600"
style={{
height: `${Math.max(2, Math.sin(i * 0.4) * 10 + Math.random() * 5 + 3)}px`,
}}
/>
))}
</div>
<span className="text-[9px] font-mono text-neutral-500 shrink-0">🔊 Auto</span>
</>
) : (
<span className="text-[9px] text-neutral-600 font-medium flex items-center gap-1.5 mx-auto">
<Music size={10} /> Sin audio de marca
</span>
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,357 @@
import React from 'react';
import { Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD } from '../../../types';
import { FieldInspector } from '../../ui/FieldInspector';
interface SceneConfiguratorProps {
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
selectedFieldId?: string | null;
onSelectField?: (fieldId: string | null) => void;
}
/** Layout options with visual icons */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: 'text' | 'media' | 'logo' }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* SceneConfigurator — Config panel for the active scene in the Template Builder.
* Name, type, duration, layout, editable fields, brand assets, transition, background.
*/
export const SceneConfigurator: React.FC<SceneConfiguratorProps> = ({
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
selectedFieldId,
onSelectField,
}) => {
const updateField = (fieldId: string, updates: Partial<ExpressField>) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.map(f =>
f.id === fieldId ? { ...f, ...updates } : f
),
});
};
const addField = (type: ExpressField['type'], label: string, brandSource?: ExpressField['brandSource']) => {
const newField: ExpressField = {
id: `field-${Date.now()}`,
type,
label,
placeholder: label,
required: false,
brandSource,
position: { x: 50, y: 50, w: type === 'text' ? 80 : 60, h: type === 'text' ? 10 : 30 },
style: {
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const addBrandAsset = (asset: BrandContentPiece) => {
const newField: ExpressField = {
id: `field-asset-${asset.id}-${Date.now()}`,
type: asset.type === 'custom-image' ? 'media' : 'text',
label: asset.name,
placeholder: asset.content.text || asset.name,
required: false,
brandAssetId: asset.id,
position: { x: 50, y: 50, w: 40, h: 20 },
style: {
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const removeField = (fieldId: string) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.filter(f => f.id !== fieldId),
});
};
return (
<div className="space-y-4">
{/* Scene name + type */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-3 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Editable Fields */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Campos editables</label>
<span className="text-[8px] text-neutral-600">{scene.editableFields.length} campos</span>
</div>
{scene.editableFields.map(field => {
const isSelected = selectedFieldId === field.id;
return (
<div
key={field.id}
onClick={() => onSelectField?.(field.id)}
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 cursor-pointer transition-all ${
isSelected
? 'bg-violet-500/10 border border-violet-500/40 ring-1 ring-violet-500/20'
: 'bg-neutral-800/50 border border-neutral-700/50 hover:border-neutral-600'
}`}
>
<span className="text-[10px]">
{field.type === 'text' ? '📝' : field.type === 'media' ? '📷' : '⚡'}
</span>
<input
type="text"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value, placeholder: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-[10px] text-neutral-300 focus:outline-none"
/>
{field.brandSource && (
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1 py-0.5 rounded shrink-0">
{`{${field.brandSource}}`}
</span>
)}
{field.brandAssetId && (
<span className="text-[7px] text-amber-400 bg-amber-500/10 px-1 py-0.5 rounded shrink-0">
Asset
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); removeField(field.id); }}
title="Quitar campo"
className="text-neutral-600 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={10} />
</button>
</div>
);
})}
{/* Field Inspector (when a field is selected) */}
{selectedFieldId && (() => {
const field = scene.editableFields.find(f => f.id === selectedFieldId);
if (!field) return null;
const brandColors = [designMD.primaryColor, designMD.secondaryColor, designMD.textColor].filter(Boolean);
return (
<FieldInspector
position={field.position}
onPositionChange={(pos) => {
updateField(field.id, {
position: { ...field.position, ...pos },
});
}}
textStyle={field.type === 'text' ? {
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
fontFamily: field.style.fontFamily,
color: field.style.color,
textAlign: field.style.textAlign as 'left' | 'center' | 'right' | undefined,
opacity: field.style.opacity,
} : undefined}
onTextStyleChange={field.type === 'text' ? (style) => {
updateField(field.id, {
style: { ...field.style, ...style },
});
} : undefined}
fieldType={field.type as 'text' | 'media' | 'logo'}
fieldLabel={field.label}
brandFont={designMD.baseFont?.split(',')[0]?.replace(/"/g, '')}
brandColors={brandColors}
/>
);
})()}
{/* Add field buttons */}
<div className="flex gap-1">
<button
onClick={() => addField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables (social handles, name, etc.) */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<>
<hr className="border-neutral-800/50" />
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name} (ID: ${asset.id})`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
</>
)}
<hr className="border-neutral-800/50" />
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨 Marca' : bg === 'solid' ? '⬛ Sólido' : bg === 'gradient' ? '🌈 Grad' : '📷 Media'}
</button>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,236 @@
import React from 'react';
import { X, Zap, FileText, Clock, AlertTriangle, Film } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
interface SegmentCardProps {
scene: ExpressScene;
position: 'before' | 'after';
designMD: DesignMD;
previewBrand: CompanyProfile | null;
onSourceChange: (source: 'brand' | 'form') => void;
onDurationChange: (seconds: number) => void;
onLabelChange: (label: string) => void;
onRequiredChange: (required: boolean) => void;
onTransitionChange: (type: string) => void;
onRemove: () => void;
}
const TRANSITION_OPTIONS = [
{ value: 'none', label: 'Sin transición' },
{ value: 'fade', label: 'Fundido' },
{ value: 'slideUp', label: 'Deslizar ↑' },
{ value: 'slideDown', label: 'Deslizar ↓' },
{ value: 'slideLeft', label: 'Deslizar ←' },
{ value: 'slideRight', label: 'Deslizar →' },
{ value: 'scale', label: 'Escala' },
];
/**
* SegmentCard — Visual card for an intro/outro segment in the SceneComposer.
*
* Shows a source toggle (Marca/Formulario), duration, badge, and description.
* Matches the boceto design with dashed borders and pill-style toggles.
*/
export const SegmentCard: React.FC<SegmentCardProps> = ({
scene,
position,
designMD,
previewBrand,
onSourceChange,
onDurationChange,
onLabelChange,
onRequiredChange,
onTransitionChange,
onRemove,
}) => {
const isIntro = position === 'before';
const isBrand = scene.segmentSource === 'brand';
// Check if brand has the required video
const brandVideoUrl = isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl;
const hasBrandVideo = !!brandVideoUrl;
const brandMissing = isBrand && !hasBrandVideo;
const borderColor = isBrand ? '#8b5cf6' : '#3b82f6';
const badgeBg = isBrand ? 'bg-violet-500/15' : 'bg-sky-500/15';
const badgeText = isBrand ? 'text-violet-300' : 'text-sky-300';
const badgeBorder = isBrand ? 'border-violet-500/30' : 'border-sky-500/30';
return (
<div
className="relative rounded-xl overflow-hidden transition-all group"
style={{
border: `1.5px ${isBrand ? 'solid' : 'dashed'} ${borderColor}40`,
backgroundColor: `${borderColor}08`,
minWidth: 160,
maxWidth: 200,
}}
>
{/* Header: title + duration + remove */}
<div className="flex items-center justify-between px-3 pt-2.5 pb-1">
<span className="text-[9px] font-bold text-white/80 tracking-wider uppercase">
{isIntro ? 'Antes' : 'Después'}
</span>
<div className="flex items-center gap-1.5">
{/* Duration */}
<div className="flex items-center gap-0.5" title="Duración del segmento">
<Clock size={8} className="text-neutral-500" />
<input
type="number"
value={scene.durationSeconds}
onChange={(e) => onDurationChange(Math.max(1, Number(e.target.value)))}
title="Duración en segundos"
className="w-8 bg-transparent text-[9px] font-mono text-neutral-400 text-right border-none outline-none"
step={0.5}
min={1}
max={30}
/>
<span className="text-[8px] text-neutral-600">s</span>
</div>
{/* Remove */}
<button
onClick={onRemove}
title={`Eliminar ${isIntro ? 'intro' : 'outro'}`}
className="w-4 h-4 rounded-full bg-red-500/20 text-red-400 hover:bg-red-500/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={8} />
</button>
</div>
</div>
{/* Source toggle */}
<div className="px-3 pb-2">
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/40 p-0.5">
<button
onClick={() => onSourceChange('brand')}
title="Usar video de intro/outro de la marca (automático)"
className={`flex-1 px-2 py-1 rounded-md text-[8px] font-semibold transition-all ${
isBrand
? 'bg-violet-600/30 text-violet-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
Marca
</button>
<button
onClick={() => onSourceChange('form')}
title="Pedir al productor que suba el video en el formulario"
className={`flex-1 px-2 py-1 rounded-md text-[8px] font-semibold transition-all ${
!isBrand
? 'bg-sky-600/30 text-sky-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
Formulario
</button>
</div>
</div>
{/* Badge */}
<div className="px-3 pb-1.5 flex items-center justify-center">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[8px] font-bold border ${badgeBg} ${badgeText} ${badgeBorder}`}>
{isBrand ? (
<><Zap size={7} /> Auto</>
) : (
<><FileText size={7} /> Campo</>
)}
</span>
</div>
{/* Description */}
<div className="px-3 pb-3 text-center">
{isBrand ? (
<>
<p className="text-[9px] text-white/70 font-medium">
{isIntro ? 'Intro de la marca' : 'Outro de la marca'}
</p>
<p className="text-[8px] text-neutral-500 mt-0.5">
{previewBrand
? (hasBrandVideo ? 'desde el Design MD' : '')
: 'desde el Design MD'}
</p>
{brandMissing && previewBrand && (
<div className="flex items-center gap-1 justify-center mt-1.5 text-amber-400">
<AlertTriangle size={8} />
<span className="text-[7px] font-medium">
{previewBrand.name} no tiene {isIntro ? 'intro' : 'outro'}
</span>
</div>
)}
{hasBrandVideo && previewBrand && (
<div className="mt-1.5 rounded-md overflow-hidden border border-neutral-700/30" style={{ height: 36 }}>
<video
src={brandVideoUrl}
muted
className="w-full h-full object-cover"
style={{ pointerEvents: 'none' }}
/>
</div>
)}
</>
) : (
<>
<p className="text-[9px] text-white/70 font-medium">
{scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre')}
</p>
<p className="text-[8px] text-neutral-500 mt-0.5">
el productor lo sube
</p>
{/* Label config */}
<input
type="text"
value={scene.segmentFieldLabel || ''}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="Etiqueta del campo"
title="Nombre del campo en el formulario"
className="mt-1.5 w-full bg-neutral-800/50 border border-neutral-700/40 rounded-md px-2 py-1 text-[8px] text-white placeholder-neutral-600 outline-none focus:border-sky-500/40"
/>
<label className="flex items-center gap-1 justify-center mt-1 cursor-pointer" title="¿Es obligatorio subir este video?">
<input
type="checkbox"
checked={scene.segmentFieldRequired ?? true}
onChange={(e) => onRequiredChange(e.target.checked)}
className="w-3 h-3 rounded bg-neutral-800 border-neutral-700 accent-sky-500"
/>
<span className="text-[7px] text-neutral-500">Obligatorio</span>
</label>
</>
)}
</div>
{/* Transition selector */}
<div className="px-3 pb-2.5 border-t border-neutral-800/40 pt-2">
<div className="flex items-center gap-1">
<Film size={7} className="text-neutral-500 shrink-0" />
<select
value={scene.segmentTransition?.type || 'fade'}
onChange={(e) => onTransitionChange(e.target.value)}
title="Transición del segmento"
className="flex-1 bg-transparent text-[8px] text-neutral-400 border-none outline-none cursor-pointer"
>
{TRANSITION_OPTIONS.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{/* Position summary */}
{(scene.segmentVideoX != null || scene.segmentVideoW != null) && (
<div className="px-3 pb-2 flex items-center justify-center gap-1 text-[7px] text-emerald-400/60 font-mono">
<span>📐</span>
<span>
{(scene.segmentVideoX ?? 50).toFixed(0)},{(scene.segmentVideoY ?? 50).toFixed(0)}
</span>
<span></span>
<span>
{(scene.segmentVideoW ?? 100).toFixed(0)}×{(scene.segmentVideoH ?? 100).toFixed(0)}
</span>
{scene.segmentVideoFit && scene.segmentVideoFit !== 'cover' && (
<span className="text-emerald-400/40">({scene.segmentVideoFit})</span>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,275 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { Move, Maximize2, Film, AlertTriangle, FileText } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
import { getAspectDimensions } from '../../../utils/expressCompiler';
import { useDragResize } from '../../../hooks/useDragResize';
const SEGMENT_VIDEO_ID = 'segment-video-frame';
interface SegmentVideoFrameProps {
scene: ExpressScene;
designMD: DesignMD;
previewBrand: CompanyProfile | null;
aspectRatio: string;
onPositionChange: (updates: Partial<ExpressScene>) => void;
}
/**
* SegmentVideoFrame — Draggable/resizable video element for intro/outro segments.
*
* Rendered on the BuilderCanvas when the active scene is a segment (intro/outro).
* Uses the shared `useDragResize` hook per AGENTS.md rules.
* Shows the brand video thumbnail or a placeholder depending on source and availability.
*/
export const SegmentVideoFrame: React.FC<SegmentVideoFrameProps> = ({
scene,
designMD,
previewBrand,
aspectRatio,
onPositionChange,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const isIntro = scene.type === 'intro';
const isBrand = scene.segmentSource === 'brand';
// Current position (defaults to fullscreen centered)
const x = scene.segmentVideoX ?? 50;
const y = scene.segmentVideoY ?? 50;
const w = scene.segmentVideoW ?? 100;
const h = scene.segmentVideoH ?? 100;
const fit = scene.segmentVideoFit ?? (isBrand
? (isIntro ? (designMD.introVideoFit || 'cover') : (designMD.outroVideoFit || 'cover'))
: 'cover');
// Brand video URL
const videoUrl = isBrand
? (isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl)
: undefined;
const hasVideo = !!videoUrl;
const dimensions = getAspectDimensions(aspectRatio);
// ── Drag/resize hook ──
const {
startDrag,
startResize,
handlePointerMove,
handlePointerUp,
isDragging,
snapGuides,
} = useDragResize({
containerRef: containerRef as React.RefObject<HTMLElement>,
onMove: useCallback((_id: string, newX: number, newY: number) => {
onPositionChange({ segmentVideoX: newX, segmentVideoY: newY });
}, [onPositionChange]),
onResize: useCallback((_id: string, newW: number, newH: number) => {
onPositionChange({ segmentVideoW: newW, segmentVideoH: newH });
}, [onPositionChange]),
snapLines: [50],
snapThreshold: 1.5,
});
// Object-fit toggle
const fitOptions: Array<{ value: 'cover' | 'contain' | 'fill'; label: string }> = [
{ value: 'cover', label: 'Cover' },
{ value: 'contain', label: 'Contain' },
{ value: 'fill', label: 'Fill' },
];
// Background color based on scene type
const bgColor = useMemo(() => {
const bg = scene.background;
if (!bg) return designMD.secondaryColor;
switch (bg.type) {
case 'brand': return designMD.secondaryColor;
case 'solid': return bg.value || '#1a1a1a';
default: return designMD.secondaryColor;
}
}, [scene.background, designMD]);
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Dot pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Mode indicator */}
<div className="absolute top-4 left-4 flex items-center gap-2 z-20">
<Film size={12} className="text-emerald-400" />
<span className="text-[9px] font-bold text-emerald-300 uppercase tracking-wider">
{isIntro ? 'Posición — Intro' : 'Posición — Outro'}
</span>
<span className={`text-[8px] px-1.5 py-0.5 rounded-full font-medium ${
isBrand ? 'bg-violet-500/15 text-violet-300' : 'bg-sky-500/15 text-sky-300'
}`}>
{isBrand ? '⚡ Marca' : '📋 Formulario'}
</span>
</div>
{/* Canvas wrapper */}
<div
ref={containerRef}
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 select-none shrink-0"
style={{
...(aspectRatio === '9:16' || aspectRatio === '4:5'
? { height: 'calc(100% - 80px)', maxWidth: '90%' }
: {
width: aspectRatio === '1:1' ? 360 : 440,
maxHeight: 'calc(100% - 80px)',
}),
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
backgroundColor: bgColor,
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Center crosshair (subtle) */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-white/[0.03] pointer-events-none z-0" />
<div className="absolute top-1/2 left-0 right-0 h-px bg-white/[0.03] pointer-events-none z-0" />
{/* Snap guides */}
{snapGuides.x !== undefined && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
style={{ left: `${snapGuides.x}%`, width: '1px', background: 'rgba(16, 185, 129, 0.5)', borderLeft: '1px dashed rgba(16, 185, 129, 0.6)' }}
/>
)}
{snapGuides.y !== undefined && (
<div
className="absolute left-0 right-0 pointer-events-none z-50"
style={{ top: `${snapGuides.y}%`, height: '1px', background: 'rgba(16, 185, 129, 0.5)', borderTop: '1px dashed rgba(16, 185, 129, 0.6)' }}
/>
)}
{/* ── Video Frame Element ── */}
<div
className="absolute transition-shadow"
style={{
left: `${x - w / 2}%`,
top: `${y - h / 2}%`,
width: `${w}%`,
height: `${h}%`,
zIndex: 10,
}}
>
<div
className={`w-full h-full rounded-md flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all ${
isDragging ? 'scale-[1.01] shadow-xl' : ''
}`}
style={{
border: '2px solid rgba(16, 185, 129, 0.6)',
outline: '2px solid rgba(16, 185, 129, 0.3)',
outlineOffset: '2px',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
}}
onPointerDown={(e) => {
e.stopPropagation();
startDrag(e, SEGMENT_VIDEO_ID, { x, y, w, h });
}}
>
{/* Video content */}
{isBrand && hasVideo ? (
<video
src={videoUrl}
muted
loop
autoPlay
playsInline
className="w-full h-full pointer-events-none"
style={{ objectFit: fit }}
/>
) : isBrand && !hasVideo ? (
<div className="flex flex-col items-center gap-2 pointer-events-none p-4">
<AlertTriangle size={20} className="text-amber-400" />
<span className="text-[9px] text-amber-300/80 text-center font-medium">
{previewBrand
? `${previewBrand.name} no tiene ${isIntro ? 'intro' : 'outro'}`
: `Sin ${isIntro ? 'intro' : 'outro'} de marca`}
</span>
<span className="text-[8px] text-neutral-500 text-center">
Puedes posicionar el marco ahora se aplicará cuando la marca tenga video
</span>
</div>
) : (
/* Form source */
<div className="flex flex-col items-center gap-2 pointer-events-none p-4">
<FileText size={20} className="text-sky-400" />
<span className="text-[9px] text-sky-300/80 text-center font-medium">
{scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre')}
</span>
<span className="text-[8px] text-neutral-500 text-center">
El productor subirá este video
</span>
</div>
)}
{/* Badge */}
<div
className="absolute -top-2.5 left-2 flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[7px] font-bold tracking-wider pointer-events-none"
style={{
backgroundColor: 'rgba(16, 185, 129, 0.15)',
color: '#6ee7b7',
border: '1px solid rgba(16, 185, 129, 0.3)',
}}
>
<Film size={7} /> {isIntro ? 'INTRO' : 'OUTRO'}
</div>
{/* Position readout */}
<div className="absolute -bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1 text-[7px] text-emerald-300/60 font-mono whitespace-nowrap pointer-events-none">
<Move size={7} /> {x.toFixed(0)},{y.toFixed(0)}
<Maximize2 size={7} className="ml-1" /> {w.toFixed(0)}×{h.toFixed(0)}
</div>
</div>
{/* Resize handle */}
<div
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 border-2 border-neutral-900 rounded-sm cursor-nwse-resize z-40 hover:opacity-80 transition-colors"
style={{ backgroundColor: '#10b981' }}
onPointerDown={(e) => startResize(e, SEGMENT_VIDEO_ID, { x, y, w, h })}
title="Redimensionar video"
/>
</div>
</div>
{/* Object-fit controls below canvas */}
<div className="mt-3 flex items-center gap-2 z-10">
<span className="text-[8px] text-neutral-500 font-mono uppercase tracking-wider">Ajuste:</span>
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
{fitOptions.map(opt => (
<button
key={opt.value}
onClick={() => onPositionChange({ segmentVideoFit: opt.value })}
title={`Ajuste de video: ${opt.label}`}
className={`px-2.5 py-1 rounded-md text-[8px] font-semibold transition-all ${
fit === opt.value
? 'bg-emerald-600/30 text-emerald-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Reset button */}
<button
onClick={() => onPositionChange({
segmentVideoX: 50,
segmentVideoY: 50,
segmentVideoW: 100,
segmentVideoH: 100,
segmentVideoFit: 'cover',
})}
title="Restablecer posición a pantalla completa"
className="px-2 py-1 rounded-md text-[8px] text-neutral-500 hover:text-neutral-300 bg-neutral-800/40 border border-neutral-700/30 transition-colors"
>
Reset
</button>
</div>
</div>
);
};
@@ -0,0 +1,692 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
ArrowLeft, Save, Video, Image as ImageIcon,
Eye, FileText, Hash, Briefcase, FlaskConical, Zap,
} from 'lucide-react';
import {
ExpressTemplate, ExpressScene, DesignMD, CompanyProfile,
TemplateField, ExpressField,
} from '../../../types';
import {
TemplateBuilderProvider, useTemplateBuilder, useSceneFieldsMap,
TemplateMeta, migrateExpressFields,
} from '../../../context/TemplateBuilderContext';
import { FieldSchemaPanel } from './FieldSchemaPanel';
import { FieldConfigPanel } from './FieldConfigPanel';
import { BuilderCanvas } from './BuilderCanvas';
import { FormPreviewPanel } from './FormPreviewPanel';
import { SceneComposer } from './SceneComposer';
import { TemplateFieldInput } from '../../shared/TemplateFieldInput';
import { LivePreviewCanvas } from '../../shared/LivePreviewCanvas';
interface TemplateBuilderProps {
company?: CompanyProfile;
designMD?: DesignMD;
availableBrands?: CompanyProfile[];
onSave: (template: ExpressTemplate) => void;
onBack: () => void;
editingTemplate?: ExpressTemplate | null;
initialFormat?: 'video' | 'image';
initialAspect?: ExpressTemplate['aspectRatio'];
}
const CATEGORIES: { value: ExpressTemplate['category']; label: string; icon: string }[] = [
{ value: 'social', label: 'Social', icon: '📱' },
{ value: 'ad', label: 'Publicidad', icon: '🎯' },
{ value: 'promo', label: 'Promo', icon: '🚀' },
{ value: 'story', label: 'Historia', icon: '💬' },
{ value: 'announcement', label: 'Anuncio', icon: '📢' },
];
function createDefaultScene(format: 'video' | 'image'): ExpressScene {
const bgType = format === 'video' ? 'video' : 'image';
const bgLabel = format === 'video' ? 'Video de fondo' : 'Imagen de fondo';
const now = Date.now();
return {
id: `scene-${now}`,
type: 'content',
name: 'Nueva Escena',
durationSeconds: 5,
layout: 'overlay',
editableFields: [],
fields: [
// Background — always index 0 (bottom z-index)
{
id: `field-bg-${now}`,
nature: 'editable-slot' as const,
type: bgType,
label: bgLabel,
required: true,
content: bgLabel,
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: 0,
isBackground: true,
},
// Title — on top
{
id: `field-title-${now + 1}`,
nature: 'editable-slot' as const,
type: 'text' as const,
label: 'Título',
required: true,
content: 'Escribe aquí',
position: { x: 50, y: 45, w: 80, h: 15 },
style: { fontSize: 36, fontWeight: 700, textAlign: 'center' as const, opacity: 100 },
formOrder: 1,
},
],
background: { type: 'brand' },
transition: { type: 'fade', duration: 10 },
};
}
/**
* TemplateBuilder — Redesigned visual template editor.
*
* Uses TemplateBuilderContext instead of EditorProvider.
* Layout: FieldSchemaPanel (left) | Canvas or FormPreview (center) | FieldConfigPanel (right)
*/
export const TemplateBuilder: React.FC<TemplateBuilderProps> = (props) => {
const format = props.editingTemplate?.format || props.initialFormat || 'video';
const initialScenes = useMemo(() => {
if (props.editingTemplate?.scenes?.length) return props.editingTemplate.scenes;
return [createDefaultScene(format)];
}, []);
const initialMeta: TemplateMeta = useMemo(() => ({
name: props.editingTemplate?.name || '',
description: props.editingTemplate?.description || '',
category: props.editingTemplate?.category || 'social',
aspectRatio: props.editingTemplate?.aspectRatio || props.initialAspect || '9:16',
format,
usesBrandAudio: props.editingTemplate?.usesBrandAudio ?? true,
}), []);
return (
<TemplateBuilderProvider
designMD={props.designMD}
company={props.company}
availableBrands={props.availableBrands}
initialScenes={initialScenes}
initialMeta={initialMeta}
>
<TemplateBuilderInner
onSave={props.onSave}
onBack={props.onBack}
editingTemplate={props.editingTemplate}
/>
</TemplateBuilderProvider>
);
};
/* ═══════════════════════════════════════════════════════════════
* Inner component — lives inside TemplateBuilderProvider
* ═══════════════════════════════════════════════════════════════ */
interface InnerProps {
onSave: (template: ExpressTemplate) => void;
onBack: () => void;
editingTemplate?: ExpressTemplate | null;
}
const TemplateBuilderInner: React.FC<InnerProps> = ({
onSave,
onBack,
editingTemplate,
}) => {
const {
scenes,
setScenes,
activeSceneId,
setActiveSceneId,
activeScene,
viewMode,
setViewMode,
templateMeta,
setTemplateMeta,
editableSlotCount,
totalFieldCount,
selectedFieldId,
previewBrand,
setPreviewBrand,
availableBrands,
resolvedDesignMD,
resolvedCompany,
fields,
testFieldData,
setTestFieldData,
testMediaFits,
setTestMediaFits,
testContainBgColors,
setTestContainBgColors,
// Segment management
addSegment,
removeSegment,
updateSegment,
introScene,
outroScene,
} = useTemplateBuilder();
const sceneFieldsMap = useSceneFieldsMap();
const [nameError, setNameError] = useState(false);
// ── Scene callbacks ──
const handleAddScene = useCallback(() => {
const newScene = createDefaultScene(templateMeta.format);
setScenes(prev => [...prev, newScene]);
setActiveSceneId(newScene.id);
}, [setScenes, setActiveSceneId, templateMeta.format]);
const handleRemoveScene = useCallback((sceneId: string) => {
setScenes(prev => {
const next = prev.filter(s => s.id !== sceneId);
if (activeSceneId === sceneId) {
setActiveSceneId(next[0]?.id || null);
}
return next;
});
}, [activeSceneId, setScenes, setActiveSceneId]);
const handleUpdateScene = useCallback((updated: ExpressScene) => {
setScenes(prev => prev.map(s => s.id === updated.id ? updated : s));
}, [setScenes]);
// ── Save ──
const handleSave = useCallback(() => {
if (!templateMeta.name.trim()) {
setNameError(true);
setTimeout(() => setNameError(false), 3000);
return;
}
// Convert all scene fields back to ExpressField format for backward compat
const updatedScenes = scenes.map(scene => {
const templateFields = sceneFieldsMap[scene.id] || [];
return {
...scene,
fields: templateFields,
editableFields: templateFieldsToExpressFields(templateFields),
};
});
const template: ExpressTemplate = {
id: editingTemplate?.id || `tpl-${Date.now()}`,
name: templateMeta.name,
description: templateMeta.description,
category: templateMeta.category,
icon: CATEGORIES.find(c => c.value === templateMeta.category)?.icon || '📐',
aspectRatio: templateMeta.aspectRatio,
format: templateMeta.format,
scenes: updatedScenes,
usesBrandAudio: templateMeta.format === 'video',
isCustom: true,
createdAt: editingTemplate?.createdAt || new Date().toISOString(),
};
onSave(template);
}, [templateMeta, scenes, sceneFieldsMap, editingTemplate, onSave]);
// ── Build a temporary ExpressTemplate from current state (for LivePreviewCanvas) ──
const buildCurrentTemplate = useCallback((): ExpressTemplate => {
const updatedScenes = scenes.map(scene => {
const templateFields = sceneFieldsMap[scene.id] || [];
return {
...scene,
fields: templateFields,
editableFields: templateFieldsToExpressFields(templateFields),
};
});
return {
id: editingTemplate?.id || 'tpl-preview',
name: templateMeta.name || 'Preview',
description: templateMeta.description,
category: templateMeta.category,
icon: CATEGORIES.find(c => c.value === templateMeta.category)?.icon || '📐',
aspectRatio: templateMeta.aspectRatio,
format: templateMeta.format,
scenes: updatedScenes,
usesBrandAudio: false,
isCustom: true,
};
}, [templateMeta, scenes, sceneFieldsMap, editingTemplate]);
return (
<div className="flex-1 flex overflow-hidden bg-neutral-950">
{/* ── Left: Field Schema Panel (full height) ── */}
<FieldSchemaPanel />
{/* ── Center: Canvas + Scene Composer ── */}
<div className="relative flex-1 flex flex-col min-h-0">
{/* Top bar — all metadata inline */}
<div className="h-11 flex items-center gap-2 px-3 border-b border-neutral-800/60 shrink-0 bg-neutral-950/80 backdrop-blur-sm z-10">
{/* Left: back */}
<button
onClick={onBack}
title="Volver a plantillas"
className="text-neutral-400 hover:text-white transition-colors shrink-0"
>
<ArrowLeft size={14} />
</button>
{/* Divider */}
<div className="w-px h-5 bg-neutral-800 shrink-0" />
{/* Name — inline editable */}
<input
type="text"
value={templateMeta.name}
onChange={(e) => { setTemplateMeta(prev => ({ ...prev, name: e.target.value })); setNameError(false); }}
placeholder="Nombre de plantilla..."
className={`bg-transparent border-none text-sm font-semibold text-white placeholder-neutral-600 focus:outline-none min-w-0 w-40 truncate transition-colors ${
nameError ? 'text-red-400 placeholder-red-500/50' : ''
}`}
/>
{/* Counter badge */}
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-sky-500/10 border border-sky-500/20 shrink-0">
<Hash size={8} className="text-sky-400" />
<span className="text-[8px] text-sky-300 font-mono">
{editableSlotCount}/{totalFieldCount}
</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Center: View mode toggle + aspect + format */}
<div className="flex items-center gap-2 shrink-0">
{/* View mode toggle */}
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
<button
onClick={() => setViewMode('design')}
title="Vista de diseño"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'design'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<Eye size={10} /> Diseño
</button>
<button
onClick={() => setViewMode('form-preview')}
title="Vista de formulario"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'form-preview'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<FileText size={10} /> Formulario
</button>
<button
onClick={() => setViewMode('test-data')}
title="Probar con datos de ejemplo"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'test-data'
? 'bg-emerald-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<FlaskConical size={10} /> Probar
</button>
</div>
{/* Brand preview selector */}
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
<Briefcase size={10} className={previewBrand ? 'text-violet-400 ml-1.5' : 'text-neutral-500 ml-1.5'} />
<select
value={previewBrand?.id ?? ''}
onChange={(e) => {
const brand = availableBrands.find(b => b.id === e.target.value) ?? null;
setPreviewBrand(brand);
}}
title="Ver con marca"
className="bg-transparent text-[9px] font-medium text-neutral-300 border-none focus:outline-none cursor-pointer px-1 py-1 appearance-none pr-4"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 4px center' }}
>
<option value="">Sin marca</option>
{availableBrands.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
{/* Aspect ratio */}
<span className="text-[10px] font-bold text-neutral-400">
{templateMeta.aspectRatio}
</span>
{/* Format badge */}
<div className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold ${
templateMeta.format === 'video'
? 'bg-violet-500/15 text-violet-300 border border-violet-500/20'
: 'bg-sky-500/15 text-sky-300 border border-sky-500/20'
}`}>
{templateMeta.format === 'video' ? <Video size={9} /> : <ImageIcon size={9} />}
{templateMeta.format === 'video' ? 'VIDEO' : 'IMG'}
</div>
</div>
{/* Divider */}
<div className="w-px h-5 bg-neutral-800 shrink-0" />
{/* Right: category + save */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Category pills (compact) */}
{CATEGORIES.map(c => (
<button
key={c.value}
onClick={() => setTemplateMeta(prev => ({ ...prev, category: c.value }))}
title={c.label}
className={`px-1.5 py-0.5 rounded text-[8px] transition-all border ${
templateMeta.category === c.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-transparent border-transparent text-neutral-600 hover:text-neutral-400'
}`}
>
{c.icon}
</button>
))}
{/* Save button */}
<button
onClick={handleSave}
title={!templateMeta.name.trim() ? 'Dale un nombre primero' : 'Guardar plantilla'}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-white text-[10px] font-semibold transition-all shadow-lg ${
!templateMeta.name.trim()
? 'bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 shadow-amber-900/30'
: 'bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 shadow-emerald-900/30'
}`}
>
<Save size={12} />
Guardar
</button>
</div>
</div>
{/* Canvas row: canvas + optional config panel */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Canvas / Form Preview / Test Data */}
<div className="flex-1 min-w-0 flex flex-col">
{viewMode === 'design' ? (
<BuilderCanvas />
) : viewMode === 'form-preview' ? (
<FormPreviewPanel />
) : (
/* test-data mode: split form + live preview */
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Test data form */}
<TestDataFormPanel />
{/* Live Remotion preview */}
<div className="flex-1 min-w-0 bg-neutral-950 relative">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
/>
<LivePreviewCanvas
template={buildCurrentTemplate()}
fieldData={testFieldData}
brand={resolvedCompany}
designMD={resolvedDesignMD}
mediaFits={testMediaFits}
containBgColors={testContainBgColors}
activeSceneId={activeSceneId}
onSceneChange={setActiveSceneId}
/>
</div>
</div>
)}
</div>
{/* Right: Field Config Panel (design/form-preview modes only, not in segment mode) */}
{selectedFieldId && viewMode !== 'test-data' && !activeScene?.segmentSource && (
<aside className="w-64 bg-neutral-900 border-l border-neutral-800/60 shrink-0 overflow-y-auto custom-scrollbar" onClick={(e) => e.stopPropagation()}>
<FieldConfigPanel />
</aside>
)}
</div>
{/* Scene Composer (video only) — always full width below canvas row */}
{templateMeta.format === 'video' && (
<div className="shrink-0 p-3 border-t border-neutral-800/60 bg-neutral-900/50">
<SceneComposer
scenes={scenes}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
onAddScene={handleAddScene}
onRemoveScene={handleRemoveScene}
designMD={resolvedDesignMD}
usesBrandAudio={templateMeta.usesBrandAudio}
format={templateMeta.format}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
previewBrand={previewBrand}
/>
</div>
)}
</div>
</div>
);
};
// ── Helper: Convert TemplateField[] to legacy ExpressField[] for backward compat ──
function templateFieldsToExpressFields(fields: TemplateField[]): ExpressField[] {
return fields.map((f): ExpressField => ({
id: f.id,
type: f.type === 'video' ? 'media' : f.type === 'image' ? (f.brandSource === 'logo' ? 'logo' : 'media') : f.type === 'shape' ? 'shape' : 'text',
label: f.label,
placeholder: f.content || f.label,
required: f.required,
brandSource: f.brandSource,
brandAssetId: f.brandAssetId,
position: { x: f.position.x, y: f.position.y, w: f.position.w, h: f.position.h },
style: {
fontSize: f.style.fontSize,
fontWeight: f.style.fontWeight,
fontFamily: f.style.fontFamily,
textAlign: f.style.textAlign,
color: f.style.color,
opacity: f.style.opacity,
shapeType: f.style.shapeType,
shapeFill: f.style.shapeFill,
shapeStroke: f.style.shapeStroke,
shapeStrokeWidth: f.style.shapeStrokeWidth,
shapeCornerRadius: f.style.shapeCornerRadius,
},
}));
}
// ── TestDataFormPanel — Form for entering test data in test-data view mode ──
/** Resolve brand variable preview for read-only display */
function resolveBrandTestValue(field: TemplateField, company: CompanyProfile, designMD: DesignMD): string {
if (!field.brandSource) return '';
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || '';
case 'tagline': return company.tagline || '';
case 'logo': return '(Logo de marca)';
case 'instagram': return company.socialLinks?.instagram || '';
case 'tiktok': return company.socialLinks?.tiktok || '';
case 'twitter': return company.socialLinks?.x || '';
case 'youtube': return company.socialLinks?.youtube || '';
case 'website': return company.socialLinks?.website || '';
default: return '';
}
}
const TestDataFormPanel: React.FC = () => {
const {
fields,
scenes,
activeSceneId,
setActiveSceneId,
resolvedDesignMD: designMD,
resolvedCompany: company,
testFieldData,
setTestFieldData,
testMediaFits,
setTestMediaFits,
testContainBgColors,
setTestContainBgColors,
} = useTemplateBuilder();
const sceneFieldsMap = useSceneFieldsMap();
// Get all editable slots across all scenes
const allEditableSlots = useMemo(() => {
const slots: { field: TemplateField; sceneId: string; sceneName: string }[] = [];
for (const scene of scenes) {
const sceneFields = sceneFieldsMap[scene.id] || [];
for (const f of sceneFields) {
if (f.nature === 'editable-slot') {
slots.push({ field: f, sceneId: scene.id, sceneName: scene.name });
}
}
}
return slots.sort((a, b) => a.field.formOrder - b.field.formOrder);
}, [scenes, sceneFieldsMap]);
const brandVars = useMemo(() => {
const vars: TemplateField[] = [];
for (const scene of scenes) {
const sceneFields = sceneFieldsMap[scene.id] || [];
for (const f of sceneFields) {
if (f.nature === 'brand-variable') vars.push(f);
}
}
return vars;
}, [scenes, sceneFieldsMap]);
// Group by scene
const sceneGroups = useMemo(() => {
const groups: { sceneId: string; sceneName: string; fields: typeof allEditableSlots }[] = [];
const seen = new Set<string>();
for (const slot of allEditableSlots) {
if (!seen.has(slot.sceneId)) {
seen.add(slot.sceneId);
groups.push({
sceneId: slot.sceneId,
sceneName: slot.sceneName,
fields: allEditableSlots.filter(s => s.sceneId === slot.sceneId),
});
}
}
return groups;
}, [allEditableSlots]);
const isMultiScene = sceneGroups.length > 1;
return (
<div className="w-[360px] shrink-0 flex flex-col border-r border-neutral-800/60 bg-neutral-950/95 backdrop-blur-sm">
{/* Header */}
<div className="px-4 py-3 border-b border-neutral-800/30 bg-gradient-to-r from-emerald-500/5 to-teal-500/5 shrink-0">
<div className="flex items-center gap-2">
<FlaskConical size={13} className="text-emerald-400" />
<h2 className="text-xs font-bold text-white">Datos de prueba</h2>
<span className="text-[9px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full font-medium">
{allEditableSlots.length} campo{allEditableSlots.length !== 1 ? 's' : ''}
</span>
</div>
<p className="text-[10px] text-neutral-500 mt-1">
Llena los campos para ver cómo se vería tu plantilla con datos reales.
</p>
</div>
{/* Scrollable fields */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 py-4 space-y-4">
{allEditableSlots.length === 0 ? (
<div className="text-center py-8">
<FlaskConical size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">No hay campos editables para probar.</p>
</div>
) : isMultiScene ? (
sceneGroups.map(group => (
<div key={group.sceneId} className="space-y-3">
<button
onClick={() => setActiveSceneId(group.sceneId)}
title={`Ir a escena: ${group.sceneName}`}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-all ${
activeSceneId === group.sceneId
? 'border-emerald-500/30 bg-emerald-500/5'
: 'border-neutral-800/50 bg-neutral-900/30 hover:border-neutral-700'
}`}
>
<div className={`w-2 h-2 rounded-full shrink-0 ${
activeSceneId === group.sceneId ? 'bg-emerald-500' : 'bg-neutral-600'
}`} />
<span className="text-[11px] font-semibold text-white flex-1">{group.sceneName}</span>
<span className="text-[9px] text-neutral-500">{group.fields.length} campo{group.fields.length !== 1 ? 's' : ''}</span>
</button>
{group.fields.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={testFieldData[field.id] || ''}
onChange={(v) => setTestFieldData(prev => ({ ...prev, [field.id]: v }))}
designMD={designMD}
mediaFit={testMediaFits[field.id]}
onMediaFitChange={(fit) => setTestMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={testContainBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setTestContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))}
</div>
))
) : (
allEditableSlots.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={testFieldData[field.id] || ''}
onChange={(v) => setTestFieldData(prev => ({ ...prev, [field.id]: v }))}
designMD={designMD}
mediaFit={testMediaFits[field.id]}
onMediaFitChange={(fit) => setTestMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={testContainBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setTestContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))
)}
{/* Brand variables (read-only) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde {company.name}
</p>
<div className="space-y-2">
{brandVars.map(field => (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{resolveBrandTestValue(field, company, designMD) || '(no configurado)'}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
+227
View File
@@ -0,0 +1,227 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Music, Play, Pause, Clock, Loader2 } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { FileDropZone } from '../ui/FileDropZone';
import { uploadMedia } from '../../utils/mediaUploader';
import { useAudioPreview } from '../../hooks/useAudioPreview';
import { getAudioDuration, formatDuration } from '../../utils/audioMetadata';
import { AudioWaveformCanvas } from '../timeline/AudioWaveformCanvas';
interface AudioPanelProps {
onClose: () => void;
}
interface AudioItem {
src: string;
name: string;
duration: number | null;
}
/**
* Panel for adding audio files. Draggable to timeline.
* Auto-routes to audio layers. Supports preview and waveform.
*/
export const AudioPanel: React.FC<AudioPanelProps> = ({ onClose }) => {
const { designMD } = useEditor();
const [localAudios, setLocalAudios] = useState<AudioItem[]>([]);
const [brandDuration, setBrandDuration] = useState<number | null>(null);
const preview = useAudioPreview();
const [playingIdx, setPlayingIdx] = useState<number | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Load brand audio duration
useEffect(() => {
if (designMD.brandAudioUrl) {
getAudioDuration(designMD.brandAudioUrl).then(d => setBrandDuration(d));
}
}, [designMD.brandAudioUrl]);
const handleUpload = useCallback(async (files: File[]) => {
const audioFiles = files.filter(f => f.type.startsWith('audio/'));
if (audioFiles.length === 0) return;
setIsUploading(true);
try {
const items: AudioItem[] = [];
for (const file of audioFiles) {
const result = await uploadMedia(file);
let duration: number | null = null;
try {
duration = await getAudioDuration(result.url);
} catch {}
items.push({ src: result.url, name: result.originalName, duration });
}
setLocalAudios(prev => [...items, ...prev]);
} catch (err) {
console.error('Audio upload failed:', err);
} finally {
setIsUploading(false);
}
}, []);
const handleTogglePreview = useCallback((src: string, idx: number) => {
if (playingIdx === idx) {
preview.pause();
setPlayingIdx(null);
} else {
preview.setSrc(src);
preview.play();
setPlayingIdx(idx);
}
}, [playingIdx, preview]);
// Stop preview when panel closes
useEffect(() => {
return () => {
preview.pause();
};
}, []);
const allAudios = [...localAudios];
const hasBrandAudio = !!designMD.brandAudioUrl;
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Audio
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* Upload */}
<FileDropZone
accept="audio/*"
multiple
onFiles={handleUpload}
label={isUploading ? 'Subiendo...' : "Subir audio"}
sublabel={isUploading ? undefined : "MP3, WAV, OGG"}
/>
{isUploading && (
<div className="flex items-center justify-center gap-2 py-2 text-violet-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-[10px] font-medium">Subiendo al servidor...</span>
</div>
)}
{/* Brand Audio */}
{hasBrandAudio && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Audio de Marca</span>
<div
className="flex flex-col gap-2 p-2.5 bg-neutral-800/50 border border-neutral-800 rounded-lg cursor-grab active:cursor-grabbing hover:border-violet-500/40 transition-colors group"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', designMD.brandAudioUrl!);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'audio', src: designMD.brandAudioUrl }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div className="flex items-center gap-3">
<button
onClick={(e) => { e.stopPropagation(); handleTogglePreview(designMD.brandAudioUrl!, -1); }}
className="w-8 h-8 rounded-md bg-violet-600/20 border border-violet-500/30 flex items-center justify-center shrink-0 hover:bg-violet-600/40 transition-colors"
title={playingIdx === -1 ? "Pausar Preview" : "Escuchar Preview"}
>
{playingIdx === -1 ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-violet-400 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-white block truncate">Jingle de Marca</span>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-500">Arrastrar al timeline</span>
{brandDuration !== null && (
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
<Clock size={8} /> {formatDuration(brandDuration)}
</span>
)}
</div>
</div>
</div>
{/* Mini Waveform */}
<div className="bg-neutral-900/50 rounded overflow-hidden">
<AudioWaveformCanvas
src={designMD.brandAudioUrl!}
width={220}
height={24}
color="rgba(139, 92, 246, 0.4)"
resolution={100}
/>
</div>
</div>
</div>
)}
{/* Uploaded Audios */}
{localAudios.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Mis Audios</span>
<div className="space-y-2">
{localAudios.map((audio, i) => (
<div
key={`audio-${i}`}
className="flex flex-col gap-2 p-2.5 bg-neutral-950/50 border border-neutral-800/60 rounded-lg cursor-grab active:cursor-grabbing hover:border-neutral-700 transition-colors group"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', audio.src);
e.dataTransfer.setData('application/json', JSON.stringify({
type: 'audio',
src: audio.src,
fileName: audio.name,
}));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div className="flex items-center gap-3">
<button
onClick={(e) => { e.stopPropagation(); handleTogglePreview(audio.src, i); }}
className="w-8 h-8 rounded-md bg-neutral-800 flex items-center justify-center shrink-0 hover:bg-neutral-700 transition-colors"
title={playingIdx === i ? "Pausar Preview" : "Escuchar Preview"}
>
{playingIdx === i ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-neutral-400 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate" title={audio.name}>{audio.name}</span>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-600">Arrastrar al timeline</span>
{audio.duration !== null && (
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
<Clock size={8} /> {formatDuration(audio.duration)}
</span>
)}
</div>
</div>
</div>
{/* Mini Waveform */}
<div className="bg-neutral-900/30 rounded overflow-hidden">
<AudioWaveformCanvas
src={audio.src}
width={220}
height={20}
color="rgba(129, 140, 248, 0.35)"
resolution={80}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state */}
{!hasBrandAudio && localAudios.length === 0 && (
<div className="text-center py-6 text-neutral-500">
<Music size={28} className="mx-auto mb-2 opacity-40" />
<p className="text-xs font-medium">Sin audio disponible</p>
<p className="text-[10px] mt-1">Sube archivos de audio o configura el jingle de marca</p>
</div>
)}
</div>
</div>
);
};
+163
View File
@@ -0,0 +1,163 @@
import React, { useCallback } from 'react';
import { X, Hexagon } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface ShapesPanelProps {
onClose: () => void;
}
interface ShapeDef {
type: TimelineElement['shapeType'];
label: string;
svg: React.ReactNode;
}
const SHAPES: ShapeDef[] = [
{
type: 'rectangle',
label: 'Rectángulo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<rect x="4" y="8" width="40" height="32" rx="3" fill="currentColor" />
</svg>
),
},
{
type: 'circle',
label: 'Círculo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<circle cx="24" cy="24" r="20" fill="currentColor" />
</svg>
),
},
{
type: 'triangle',
label: 'Triángulo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<polygon points="24,4 44,44 4,44" fill="currentColor" />
</svg>
),
},
{
type: 'star',
label: 'Estrella',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<polygon points="24,2 29,17 46,17 33,27 38,44 24,34 10,44 15,27 2,17 19,17" fill="currentColor" />
</svg>
),
},
{
type: 'line',
label: 'Línea',
svg: (
<svg viewBox="0 0 48 12" className="w-10 h-4">
<line x1="4" y1="6" x2="44" y2="6" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
),
},
{
type: 'arrow',
label: 'Flecha',
svg: (
<svg viewBox="0 0 48 24" className="w-10 h-5">
<line x1="4" y1="12" x2="36" y2="12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<polygon points="32,4 46,12 32,20" fill="currentColor" />
</svg>
),
},
];
/**
* ShapesPanel — Grid of basic shapes to insert into the canvas.
*/
export const ShapesPanel: React.FC<ShapesPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const addShape = useCallback((shapeDef: ShapeDef) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
// Find or create a visual layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'shape',
content: shapeDef.label,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 35,
y: 35,
width: 30,
shapeType: shapeDef.type,
shapeFill: '#ffffff',
shapeStroke: 'none',
shapeStrokeWidth: 0,
shapeCornerRadius: 0,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
return (
<div className="w-72 bg-neutral-950 border-r border-neutral-800 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800 shrink-0">
<div className="flex items-center gap-2">
<Hexagon size={16} className="text-violet-400" />
<h3 className="text-sm font-bold text-white">Formas</h3>
</div>
<button
onClick={onClose}
title="Cerrar panel"
className="p-1 rounded hover:bg-neutral-800 text-neutral-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Shapes Grid */}
<div className="p-4 flex-1 overflow-y-auto custom-scrollbar">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-3">Básicas</label>
<div className="grid grid-cols-3 gap-2">
{SHAPES.map((shape) => (
<button
key={shape.type}
onClick={() => addShape(shape)}
title={`Insertar ${shape.label}`}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-xl border border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:text-violet-400 hover:border-violet-500/40 hover:bg-violet-500/5 transition-all group"
>
<div className="text-neutral-500 group-hover:text-violet-400 transition-colors">
{shape.svg}
</div>
<span className="text-[9px] font-medium">{shape.label}</span>
</button>
))}
</div>
</div>
</div>
);
};
+407
View File
@@ -0,0 +1,407 @@
import React, { useState, useCallback } from 'react';
import { Search, Volume2, Plus, Loader2 } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface SfxCategory {
name: string;
emoji: string;
effects: SoundEffect[];
}
interface SoundEffect {
name: string;
description: string;
durationSec: number;
// These would be actual URLs in production — for now they are placeholders
// that get generated/loaded on demand
generator: 'tone' | 'noise' | 'click';
frequency?: number;
}
const SFX_CATEGORIES: SfxCategory[] = [
{
name: 'Transiciones',
emoji: '🔄',
effects: [
{ name: 'Whoosh', description: 'Paso rápido', durationSec: 0.8, generator: 'noise' },
{ name: 'Swoosh Suave', description: 'Movimiento suave', durationSec: 0.6, generator: 'noise' },
{ name: 'Click', description: 'Click mecánico', durationSec: 0.2, generator: 'click' },
{ name: 'Pop', description: 'Aparición', durationSec: 0.3, generator: 'click', frequency: 800 },
],
},
{
name: 'UI / Notificaciones',
emoji: '🔔',
effects: [
{ name: 'Ding', description: 'Notificación', durationSec: 0.5, generator: 'tone', frequency: 880 },
{ name: 'Beep', description: 'Alerta simple', durationSec: 0.3, generator: 'tone', frequency: 440 },
{ name: 'Error', description: 'Error/rechazo', durationSec: 0.4, generator: 'tone', frequency: 220 },
{ name: 'Success', description: 'Éxito/aprobado', durationSec: 0.6, generator: 'tone', frequency: 660 },
],
},
{
name: 'Impacto',
emoji: '💥',
effects: [
{ name: 'Boom', description: 'Impacto bajo', durationSec: 1.0, generator: 'noise' },
{ name: 'Hit Suave', description: 'Golpe leve', durationSec: 0.4, generator: 'noise' },
{ name: 'Drum Hit', description: 'Tambor', durationSec: 0.5, generator: 'tone', frequency: 80 },
],
},
{
name: 'Ambientes',
emoji: '🌊',
effects: [
{ name: 'Lluvia', description: 'Sonido de lluvia', durationSec: 3.0, generator: 'noise' },
{ name: 'Viento', description: 'Brisa suave', durationSec: 3.0, generator: 'noise' },
{ name: 'Estática', description: 'Ruido blanco', durationSec: 2.0, generator: 'noise' },
],
},
];
/**
* Generate a simple sound effect using Web Audio API and return as blob URL.
*/
function generateSfx(effect: SoundEffect): string {
const ctx = new OfflineAudioContext(1, 44100 * effect.durationSec, 44100);
if (effect.generator === 'tone') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = effect.frequency ?? 440;
gain.gain.setValueAtTime(0.5, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(effect.durationSec);
} else if (effect.generator === 'click') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = effect.frequency ?? 1000;
gain.gain.setValueAtTime(0.8, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(effect.durationSec);
} else {
// Noise generator
const bufferSize = ctx.sampleRate * effect.durationSec;
const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = noiseBuffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.9);
source.connect(gain);
gain.connect(ctx.destination);
source.start(0);
}
// OfflineAudioContext renders synchronously in terms of API but returns a promise
// We'll create a placeholder and update async
return ''; // Will be replaced
}
/**
* Generate SFX and return a blob URL asynchronously.
*/
async function generateSfxAsync(effect: SoundEffect): Promise<string> {
const sampleRate = 44100;
const duration = effect.durationSec;
const ctx = new OfflineAudioContext(1, sampleRate * duration, sampleRate);
if (effect.generator === 'tone') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = effect.frequency ?? 440;
gain.gain.setValueAtTime(0.5, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(duration);
} else if (effect.generator === 'click') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = effect.frequency ?? 1000;
gain.gain.setValueAtTime(0.8, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(duration);
} else {
const bufferSize = sampleRate * duration;
const noiseBuffer = ctx.createBuffer(1, bufferSize, sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = noiseBuffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.9);
source.connect(gain);
gain.connect(ctx.destination);
source.start(0);
}
const rendered = await ctx.startRendering();
// Convert to WAV blob
const numChannels = 1;
const length = rendered.length * numChannels * 2 + 44;
const buffer = new ArrayBuffer(length);
const view = new DataView(buffer);
// WAV header
const writeString = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
writeString(0, 'RIFF');
view.setUint32(4, length - 8, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, rendered.length * numChannels * 2, true);
const channelData = rendered.getChannelData(0);
let offset = 44;
for (let i = 0; i < rendered.length; i++) {
const sample = Math.max(-1, Math.min(1, channelData[i]));
view.setInt16(offset, sample * 0x7FFF, true);
offset += 2;
}
const blob = new Blob([buffer], { type: 'audio/wav' });
return URL.createObjectURL(blob);
}
interface SoundEffectsPanelProps {
onClose: () => void;
}
/**
* SoundEffectsPanel — Categorized SFX library with Web Audio generated effects.
*/
export const SoundEffectsPanel: React.FC<SoundEffectsPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const [search, setSearch] = useState('');
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [insertingId, setInsertingId] = useState<string | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>(
Object.fromEntries(SFX_CATEGORIES.map(c => [c.name, true]))
);
// Preview a sound effect
const handlePreview = useCallback(async (effect: SoundEffect) => {
const id = effect.name;
setPreviewingId(id);
try {
const url = await generateSfxAsync(effect);
const audio = new Audio(url);
audio.volume = 0.5;
audio.play();
audio.onended = () => {
setPreviewingId(null);
URL.revokeObjectURL(url);
};
} catch {
setPreviewingId(null);
}
}, []);
// Insert into timeline
const handleInsert = useCallback(async (effect: SoundEffect) => {
const id = effect.name;
setInsertingId(id);
try {
const url = await generateSfxAsync(effect);
// Upload to server for persistence
const response = await fetch(url);
const blob = await response.blob();
const file = new File([blob], `sfx-${effect.name.toLowerCase().replace(/\s+/g, '-')}.wav`, { type: 'audio/wav' });
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
let persistentUrl = url;
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
persistentUrl = uploadData.url;
URL.revokeObjectURL(url);
}
// Find or create audio layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type !== 'audio') {
let audioLayer = layers.find(l => l.type === 'audio');
if (!audioLayer) {
audioLayer = { id: 'layer-audio-' + Date.now(), name: 'Audio', type: 'audio' };
setLayers(prev => [...prev, audioLayer!]);
}
targetLayerId = audioLayer.id;
setActiveLayerId(targetLayerId);
}
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newElement: TimelineElement = {
id: 'sfx-' + Date.now(),
layerId: targetLayerId,
type: 'audio',
content: persistentUrl,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + Math.round(effect.durationSec * 30)),
x: 0,
y: 0,
originalFileName: `SFX: ${effect.name}`,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newElement.id);
} catch (err) {
console.error('SFX insert error:', err);
} finally {
setInsertingId(null);
}
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
// Filter
const filteredCategories = SFX_CATEGORIES
.map(cat => ({
...cat,
effects: cat.effects.filter(e =>
!search || e.name.toLowerCase().includes(search.toLowerCase()) || e.description.toLowerCase().includes(search.toLowerCase())
),
}))
.filter(cat => cat.effects.length > 0);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Volume2 size={14} className="text-emerald-400" />
Efectos de Sonido
</h3>
<button onClick={onClose} title="Cerrar" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
</button>
</div>
{/* Search */}
<div className="p-3 border-b border-neutral-800/50">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar efectos..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-emerald-500/50"
/>
</div>
</div>
{/* Categories */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
{filteredCategories.map((cat) => (
<div key={cat.name}>
<button
onClick={() => setExpandedCategories(prev => ({ ...prev, [cat.name]: !prev[cat.name] }))}
title={`Categoría ${cat.name}`}
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/50 transition-colors text-left"
>
<span className="text-sm">{cat.emoji}</span>
<span className="text-[11px] font-semibold text-neutral-300 flex-1">{cat.name}</span>
<span className="text-[9px] text-neutral-600">{cat.effects.length}</span>
</button>
{expandedCategories[cat.name] && (
<div className="ml-1 space-y-0.5 mt-0.5">
{cat.effects.map((effect) => {
const effectId = effect.name;
const isPreviewing = previewingId === effectId;
const isInserting = insertingId === effectId;
return (
<div
key={effectId}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/40 transition-colors group"
>
{/* Preview button */}
<button
onClick={() => handlePreview(effect)}
disabled={isPreviewing}
title={`Previsualizar ${effect.name}`}
className={`p-1 rounded transition-colors ${
isPreviewing
? 'text-emerald-400 animate-pulse'
: 'text-neutral-600 hover:text-emerald-400'
}`}
>
<Volume2 size={12} />
</button>
{/* Name + Desc */}
<div className="flex-1 min-w-0">
<div className="text-[10px] font-medium text-neutral-300 truncate">{effect.name}</div>
<div className="text-[8px] text-neutral-600 truncate">{effect.description} · {effect.durationSec}s</div>
</div>
{/* Insert button */}
<button
onClick={() => handleInsert(effect)}
disabled={isInserting}
title={`Insertar ${effect.name}`}
className="p-1 rounded text-neutral-600 hover:text-emerald-400 opacity-0 group-hover:opacity-100 transition-all"
>
{isInserting ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
</button>
</div>
);
})}
</div>
)}
</div>
))}
{filteredCategories.length === 0 && (
<div className="text-center py-6 text-neutral-600 text-xs">
<Volume2 size={24} className="mx-auto mb-2 opacity-30" />
<p>No se encontraron efectos</p>
</div>
)}
</div>
</div>
);
};
+235
View File
@@ -0,0 +1,235 @@
import React, { useCallback } from 'react';
import { X, Stamp, Image as ImageIcon, Type, AtSign, Globe, Instagram } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { FileDropZone } from '../ui/FileDropZone';
import { TimelineElement } from '../../types';
interface StickersPanelProps {
onClose: () => void;
}
/**
* Panel for brand assets: branded text presets, social handles, stickers.
* Text presets use the brand font, color, and name from designMD.
*/
export const StickersPanel: React.FC<StickersPanelProps> = ({ onClose }) => {
const {
designMD, brandContent,
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const brandName = designMD.brandName || 'Mi Marca';
const font = designMD.baseFont || 'system-ui';
const color = designMD.textColor || '#ffffff';
const social = designMD.socialHandles || {};
const brandContentThumbnails = (brandContent || [])
.filter(p => p.thumbnail)
.map(p => ({ src: p.thumbnail!, name: p.name, id: p.id }));
const legacyStickers = designMD.brandStickers || [];
// Add branded text element to a visual layer
const addBrandText = useCallback((content: string, fontSize?: number, y?: number) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'text',
content,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 50,
y: y ?? 50,
fontSize,
fontFamily: font,
color: color,
useBranding: true,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId, font, color]);
// Build social text presets
const socialPresets: { label: string; content: string; icon: React.ReactNode }[] = [];
if (social.instagram) socialPresets.push({ label: 'Instagram', content: social.instagram, icon: <Instagram size={12} /> });
if (social.tiktok) socialPresets.push({ label: 'TikTok', content: social.tiktok, icon: <AtSign size={12} /> });
if (social.twitter) socialPresets.push({ label: 'Twitter/X', content: social.twitter, icon: <AtSign size={12} /> });
if (social.youtube) socialPresets.push({ label: 'YouTube', content: social.youtube, icon: <AtSign size={12} /> });
if (social.website) socialPresets.push({ label: 'Web', content: social.website, icon: <Globe size={12} /> });
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Stamp size={14} className="text-amber-400" />
Marca
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* ═══ Textos de Marca ═══ */}
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Textos de Marca</span>
<div className="space-y-1.5">
{/* Brand name — large */}
<button
onClick={() => addBrandText(brandName, 64, 40)}
title={`Añadir "${brandName}" como título`}
className="w-full flex items-center gap-2.5 px-3 py-2.5 bg-neutral-950/60 border border-amber-900/30 rounded-lg text-left hover:border-amber-500/40 hover:bg-amber-950/20 transition-all group"
>
<div className="w-8 h-8 rounded-md bg-amber-600/15 border border-amber-500/30 flex items-center justify-center shrink-0">
<Type size={14} className="text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-bold text-white block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Título grande · 64px</span>
</div>
</button>
{/* Brand name — subtitle */}
<button
onClick={() => addBrandText(brandName, 36, 50)}
title={`Añadir "${brandName}" como subtítulo`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
<Type size={12} className="text-neutral-400 group-hover:text-amber-400 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Subtítulo · 36px</span>
</div>
</button>
{/* Brand name — small watermark */}
<button
onClick={() => addBrandText(brandName, 20, 90)}
title={`Añadir "${brandName}" como marca de agua`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
<Type size={10} className="text-neutral-500 group-hover:text-amber-400 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[10px] text-neutral-400 block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Marca de agua · 20px</span>
</div>
</button>
</div>
</div>
{/* ═══ Redes Sociales ═══ */}
{socialPresets.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Redes Sociales</span>
<div className="space-y-1.5">
{socialPresets.map((sp) => (
<button
key={sp.label}
onClick={() => addBrandText(sp.content, 28, 85)}
title={`Añadir ${sp.label}: ${sp.content}`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-violet-500/30 hover:bg-violet-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-violet-600/15 border border-violet-500/30 flex items-center justify-center shrink-0 text-violet-400">
{sp.icon}
</div>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate">{sp.content}</span>
<span className="text-[9px] text-neutral-600">{sp.label} · 28px</span>
</div>
</button>
))}
</div>
</div>
)}
{/* ═══ Contenido Visual de Marca ═══ */}
{brandContentThumbnails.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Contenido Visual</span>
<div className="grid grid-cols-2 gap-2">
{brandContentThumbnails.map(item => (
<div
key={item.id}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', item.src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src: item.src, brandContentId: item.id }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<img src={item.src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt={item.name} draggable={false} />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center pointer-events-none">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
<span className="text-[8px] text-neutral-300 mt-0.5">{item.name}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Legacy Stickers */}
{legacyStickers.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Stickers</span>
<div className="grid grid-cols-2 gap-2">
{legacyStickers.map((src, i) => (
<div
key={`sticker-${i}`}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<img src={src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt="Sticker" draggable={false} />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Upload */}
<FileDropZone
accept="image/*"
multiple
onFiles={() => {}}
label="Subir assets de marca"
sublabel="PNG con transparencia"
/>
</div>
</div>
);
};
+266
View File
@@ -0,0 +1,266 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Search, Film, Image as ImageIcon, Loader2, Download, ExternalLink } from 'lucide-react';
import { searchStockPhotos, searchStockVideos, downloadStockToServer, StockPhoto, StockVideo } from '../../utils/stockMediaApi';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
type MediaType = 'photos' | 'videos';
/**
* StockMediaTab — Search and insert stock photos/videos from Pexels.
*/
export const StockMediaTab: React.FC = () => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const [query, setQuery] = useState('');
const [mediaType, setMediaType] = useState<MediaType>('photos');
const [photos, setPhotos] = useState<StockPhoto[]>([]);
const [videos, setVideos] = useState<StockVideo[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
// ─── Search ───
const doSearch = useCallback(async (q: string, p: number, type: MediaType, append = false) => {
setIsLoading(true);
try {
if (type === 'photos') {
const result = await searchStockPhotos(q || 'trending', p);
setPhotos(prev => append ? [...prev, ...result.items] : result.items);
setHasMore(result.hasMore);
} else {
const result = await searchStockVideos(q || 'trending', p);
setVideos(prev => append ? [...prev, ...result.items] : result.items);
setHasMore(result.hasMore);
}
} finally {
setIsLoading(false);
}
}, []);
// Debounced search on query change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
doSearch(query, 1, mediaType);
}, 500);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [query, mediaType, doSearch]);
// Load more (infinite scroll)
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) {
const nextPage = page + 1;
setPage(nextPage);
doSearch(query, nextPage, mediaType, true);
}
}, { threshold: 0.5 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, isLoading, page, query, mediaType, doSearch]);
// ─── Insert into canvas ───
const insertPhoto = useCallback(async (photo: StockPhoto) => {
setIsDownloading(photo.id);
try {
// Download to server for persistence
const persistentUrl = await downloadStockToServer(photo.mediumUrl, `pexels-${photo.id}.jpg`);
insertElement(persistentUrl, 'image');
} catch {
// Fallback: use direct URL
insertElement(photo.mediumUrl, 'image');
} finally {
setIsDownloading(null);
}
}, []);
const insertVideo = useCallback(async (video: StockVideo) => {
setIsDownloading(video.id);
try {
const persistentUrl = await downloadStockToServer(video.videoUrl, `pexels-${video.id}.mp4`);
insertElement(persistentUrl, 'video');
} catch {
insertElement(video.videoUrl, 'video');
} finally {
setIsDownloading(null);
}
}, []);
const insertElement = useCallback((src: string, type: 'image' | 'video') => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Visual', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type,
content: src,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + (type === 'video' ? 150 : 100)),
x: 25,
y: 25,
width: 50,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
const items = mediaType === 'photos' ? photos : videos;
return (
<div className="flex flex-col h-full">
{/* Search + Type Toggle */}
<div className="p-3 space-y-2 border-b border-neutral-800/50">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar en Pexels..."
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-violet-500/50"
/>
</div>
<div className="flex gap-1">
<button
onClick={() => setMediaType('photos')}
title="Buscar fotos"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
mediaType === 'photos'
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<ImageIcon size={12} /> Fotos
</button>
<button
onClick={() => setMediaType('videos')}
title="Buscar videos"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
mediaType === 'videos'
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<Film size={12} /> Videos
</button>
</div>
</div>
{/* Results Grid */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
{items.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-32 text-neutral-600 text-xs">
<Search size={24} className="mb-2 opacity-50" />
<span>Busca fotos o videos gratis</span>
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
{mediaType === 'photos'
? photos.map((photo) => (
<button
key={photo.id}
onClick={() => insertPhoto(photo)}
title={`${photo.alt || 'Foto'}${photo.photographer}`}
className="relative group rounded-lg overflow-hidden aspect-square bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
disabled={isDownloading === photo.id}
>
<img
src={photo.thumbUrl}
alt={photo.alt}
loading="lazy"
className="w-full h-full object-cover"
/>
{/* Overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end">
<span className="text-[8px] text-white/80 px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity truncate">
📷 {photo.photographer}
</span>
</div>
{isDownloading === photo.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 size={20} className="text-violet-400 animate-spin" />
</div>
)}
</button>
))
: videos.map((video) => (
<button
key={video.id}
onClick={() => insertVideo(video)}
title={`Video — ${video.photographer} (${video.duration}s)`}
className="relative group rounded-lg overflow-hidden aspect-video bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
disabled={isDownloading === video.id}
>
<img
src={video.thumbUrl}
alt="Video thumbnail"
loading="lazy"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Film size={24} className="text-white/70 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<span className="absolute bottom-1 right-1 text-[8px] text-white/80 bg-black/60 rounded px-1 py-0.5">
{video.duration}s
</span>
{isDownloading === video.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 size={20} className="text-violet-400 animate-spin" />
</div>
)}
</button>
))}
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-center py-4">
<Loader2 size={16} className="text-violet-400 animate-spin" />
</div>
)}
{/* Infinite scroll sentinel */}
{hasMore && <div ref={sentinelRef} className="h-4" />}
{/* Pexels attribution */}
{items.length > 0 && (
<div className="flex items-center justify-center gap-1 py-3 text-[9px] text-neutral-600">
<ExternalLink size={8} />
Fotos proporcionadas por <a href="https://pexels.com" target="_blank" rel="noopener" className="text-neutral-500 underline">Pexels</a>
</div>
)}
</div>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React, { useCallback } from 'react';
import { X, Type, Plus, AlignLeft, AlignCenter, Heading1, Heading2, Subtitles } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface TextPanelProps {
onClose: () => void;
}
const TEXT_PRESETS = [
{ label: 'Título', icon: <Heading1 size={14} />, content: 'Título', fontSize: 72, y: 30 },
{ label: 'Subtítulo', icon: <Heading2 size={14} />, content: 'Subtítulo', fontSize: 48, y: 50 },
{ label: 'Cuerpo', icon: <AlignLeft size={14} />, content: 'Texto de cuerpo', fontSize: 32, y: 60 },
{ label: 'Lower Third', icon: <Subtitles size={14} />, content: 'Lower Third', fontSize: 28, y: 85 },
{ label: 'Centrado', icon: <AlignCenter size={14} />, content: 'Texto Centrado', fontSize: 56, y: 50 },
];
/**
* Panel for adding text elements. Auto-routes to a visual layer.
*/
export const TextPanel: React.FC<TextPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const addText = useCallback((content: string, fontSize?: number, y?: number) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
// Find or create a visual layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'text',
content,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 50,
y: y ?? 50,
fontSize,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId]);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Type size={14} className="text-violet-400" />
Texto
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* Quick add */}
<button
onClick={() => addText('Nuevo Texto')}
title="Añadir texto rápido"
className="w-full flex items-center justify-center gap-2 py-2.5 bg-violet-600/20 border border-violet-500/40 text-violet-300 hover:bg-violet-600/30 hover:border-violet-400/60 rounded-lg transition-all text-sm font-medium"
>
<Plus size={14} />
Añadir Texto
</button>
{/* Presets */}
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest">Plantillas</span>
<div className="grid gap-1.5">
{TEXT_PRESETS.map((preset) => (
<button
key={preset.label}
onClick={() => addText(preset.content, preset.fontSize, preset.y)}
title={`Añadir ${preset.label}`}
className="flex items-center gap-2.5 px-3 py-2 bg-neutral-950/50 border border-neutral-800/60 rounded-lg text-neutral-400 hover:text-white hover:border-neutral-700 hover:bg-neutral-800/50 transition-all text-left group"
>
<span className="text-neutral-500 group-hover:text-violet-400 transition-colors">{preset.icon}</span>
<div className="flex flex-col">
<span className="text-[11px] font-medium leading-tight">{preset.label}</span>
<span className="text-[9px] text-neutral-600">{preset.fontSize}px</span>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,334 @@
import React, { useState, useEffect } from 'react';
import { Music, Trash2, Wand2, Loader2, Volume2, VolumeX, Subtitles } from 'lucide-react';
import { TimelineElement, DesignMD } from '../../types';
import { AudioWaveformCanvas } from '../timeline/AudioWaveformCanvas';
import { formatDuration, getAudioDuration } from '../../utils/audioMetadata';
import { CaptionStylePicker } from '../captions/CaptionStylePicker';
import { generateCaptionElements, CaptionStyle } from '../../utils/captionGenerator';
interface AudioElementPropertiesProps {
element: TimelineElement;
elementIndex: number;
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
setSelectedElementId: (id: string | null) => void;
timeUnit: 'frames' | 'seconds';
activeLayerId: string;
timelineElements: TimelineElement[];
}
/**
* Properties panel for audio elements.
* Shows volume, fade in/out, waveform preview, and subtitle generation.
*/
export const AudioElementProperties: React.FC<AudioElementPropertiesProps> = ({
element: el,
elementIndex: i,
setTimelineElements,
setSelectedElementId,
timeUnit,
activeLayerId,
timelineElements,
}) => {
const [isTranscribing, setIsTranscribing] = useState(false);
const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [showCaptionPicker, setShowCaptionPicker] = useState(false);
const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false);
const update = (updates: Partial<TimelineElement>) => {
setTimelineElements(prev => prev.map((e, idx) => idx === i ? { ...e, ...updates } : e));
};
// Load audio duration
useEffect(() => {
if (el.content) {
getAudioDuration(el.content).then(d => setAudioDuration(d));
}
}, [el.content]);
const clipDuration = el.endFrame - el.startFrame;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
<h2 className="text-xs font-bold text-white flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Audio
</h2>
<button
onClick={() => {
setTimelineElements(prev => prev.filter(e => e.id !== el.id));
setSelectedElementId(null);
}}
title="Eliminar Audio"
className="text-neutral-500 hover:text-red-400 p-1 rounded-md hover:bg-red-500/10 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
<div className="px-4 py-3 overflow-y-auto custom-scrollbar flex-1 space-y-5">
{/* ═══ Waveform Preview ═══ */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Forma de Onda</label>
<div className="bg-neutral-950 border border-neutral-800 rounded-lg p-2 relative overflow-hidden">
<AudioWaveformCanvas
src={el.content}
width={240}
height={48}
color="rgba(129, 140, 248, 0.6)"
/>
{audioDuration !== null && (
<div className="absolute bottom-1 right-2 text-[9px] text-neutral-500 font-mono">
{formatDuration(audioDuration)}
</div>
)}
</div>
{el.originalFileName && (
<p className="text-[9px] text-neutral-600 mt-1 truncate">{el.originalFileName}</p>
)}
</div>
{/* ═══ Volume ═══ */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Volumen</label>
<span className="text-[10px] text-neutral-400 font-mono">{Math.round((el.volume ?? 1) * 100)}%</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => update({ volume: el.volume === 0 ? 1 : 0 })}
className={`p-1.5 rounded-md transition-colors ${el.volume === 0 ? 'bg-red-500/20 text-red-400' : 'bg-neutral-800 text-neutral-400 hover:text-white'}`}
title={el.volume === 0 ? "Activar Sonido" : "Silenciar"}
>
{el.volume === 0 ? <VolumeX size={14} /> : <Volume2 size={14} />}
</button>
<input
type="range"
min="0" max="200" step="1"
value={Math.round((el.volume ?? 1) * 100)}
onChange={(e) => update({ volume: Number(e.target.value) / 100 })}
className="flex-1 accent-violet-500 h-1"
title="Volumen del clip"
/>
</div>
</div>
{/* ═══ Fade In / Out ═══ */}
<div className="space-y-3">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Fundidos</label>
{/* Fade In */}
<div>
<div className="flex justify-between text-[10px] text-neutral-500 mb-0.5">
<span>Fade In</span>
<span className="font-mono">
{el.fadeInFrames ?? 0}f ({((el.fadeInFrames ?? 0) / 30).toFixed(1)}s)
</span>
</div>
<input
type="range"
min="0" max={Math.floor(clipDuration / 2)} step="1"
value={el.fadeInFrames ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value);
update({ fadeInFrames: v > 0 ? v : undefined });
}}
className="w-full accent-amber-500 h-1"
title="Duración del fundido de entrada"
/>
</div>
{/* Fade Out */}
<div>
<div className="flex justify-between text-[10px] text-neutral-500 mb-0.5">
<span>Fade Out</span>
<span className="font-mono">
{el.fadeOutFrames ?? 0}f ({((el.fadeOutFrames ?? 0) / 30).toFixed(1)}s)
</span>
</div>
<input
type="range"
min="0" max={Math.floor(clipDuration / 2)} step="1"
value={el.fadeOutFrames ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value);
update({ fadeOutFrames: v > 0 ? v : undefined });
}}
className="w-full accent-amber-500 h-1"
title="Duración del fundido de salida"
/>
</div>
{/* Quick Fade Presets */}
<div className="flex gap-1.5">
{[
{ label: 'Sin Fade', fadeIn: 0, fadeOut: 0 },
{ label: 'Suave', fadeIn: 15, fadeOut: 15 },
{ label: 'Largo', fadeIn: 45, fadeOut: 45 },
].map(preset => {
const isActive = (el.fadeInFrames ?? 0) === preset.fadeIn && (el.fadeOutFrames ?? 0) === preset.fadeOut;
return (
<button
key={preset.label}
onClick={() => update({
fadeInFrames: preset.fadeIn > 0 ? preset.fadeIn : undefined,
fadeOutFrames: preset.fadeOut > 0 ? preset.fadeOut : undefined,
})}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
isActive
? 'bg-amber-600/20 border-amber-500/50 text-amber-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300'
}`}
title={preset.label}
>
{preset.label}
</button>
);
})}
</div>
</div>
{/* ═══ Timing ═══ */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Tiempos</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-neutral-500 mb-0.5">Inicio ({timeUnit === 'frames' ? 'f' : 's'})</label>
<input
type="number"
step={timeUnit === 'seconds' ? 0.01 : 1}
value={timeUnit === 'frames' ? el.startFrame : Number((el.startFrame / 30).toFixed(2))}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
update({ startFrame: timeUnit === 'seconds' ? Math.round(val * 30) : Math.round(val) });
}}
className="bg-neutral-950 rounded-lg px-2 py-1.5 w-full border border-neutral-800 outline-none text-center font-mono text-xs focus:border-violet-500/50"
/>
</div>
<div>
<label className="block text-[10px] text-neutral-500 mb-0.5">Fin ({timeUnit === 'frames' ? 'f' : 's'})</label>
<input
type="number"
step={timeUnit === 'seconds' ? 0.01 : 1}
value={timeUnit === 'frames' ? el.endFrame : Number((el.endFrame / 30).toFixed(2))}
onChange={(e) => {
const val = parseFloat(e.target.value) || 1;
update({ endFrame: timeUnit === 'seconds' ? Math.round(val * 30) : Math.round(val) });
}}
className="bg-neutral-950 rounded-lg px-2 py-1.5 w-full border border-neutral-800 outline-none text-center font-mono text-xs focus:border-violet-500/50"
/>
</div>
</div>
</div>
{/* ═══ Subtítulos ═══ */}
<div className="space-y-2">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Subtítulos</label>
<button
disabled={isTranscribing}
onClick={async () => {
try {
setIsTranscribing(true);
const res = await fetch(el.content);
const blob = await res.blob();
const file = new File([blob], "audio.mp3", { type: el.content.startsWith("data:") ? "audio/mpeg" : blob.type });
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
if (data.text) {
const newTextEl: TimelineElement = {
id: Date.now().toString(),
layerId: activeLayerId,
type: 'text',
content: data.text,
startFrame: el.startFrame,
endFrame: el.endFrame,
x: 20, y: 80,
shadowOffset: 3, shadowBlur: 6
};
setTimelineElements(prev => [...prev, newTextEl]);
}
} catch (err) {
console.error("Error generating subtitles:", err);
alert("Error al generar subtítulos.");
} finally {
setIsTranscribing(false);
}
}}
title="Generar Subtítulos Automáticos"
className={`w-full font-medium py-2 px-3 rounded-lg transition-colors flex items-center justify-center gap-2 text-xs ${isTranscribing ? 'bg-neutral-800 text-neutral-400 cursor-not-allowed' : 'bg-violet-600 hover:bg-violet-500 text-white'}`}
>
{isTranscribing ? (
<><Loader2 size={12} className="animate-spin" /> Transcribiendo...</>
) : (
<><Wand2 size={12} /> Generar Subtítulos</>
)}
</button>
<p className="text-[9px] text-neutral-600 text-center">Whisper Large V3 (Groq)</p>
{/* Auto-Captions Button */}
<button
onClick={() => setShowCaptionPicker(true)}
title="Generar subtítulos sincronizados palabra por palabra"
className="w-full font-medium py-2 px-3 rounded-lg transition-colors flex items-center justify-center gap-2 text-xs bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 text-white"
>
<Subtitles size={12} /> Auto-Captions (Palabra x Palabra)
</button>
</div>
</div>
{/* Caption Style Picker Modal */}
<CaptionStylePicker
isOpen={showCaptionPicker}
onClose={() => setShowCaptionPicker(false)}
isLoading={isGeneratingCaptions}
onGenerate={async (style: CaptionStyle) => {
try {
setIsGeneratingCaptions(true);
// 1. Fetch audio file
const res = await fetch(el.content);
const blob = await res.blob();
const file = new File([blob], "audio.mp3", { type: blob.type || "audio/mpeg" });
const formData = new FormData();
formData.append('file', file);
// 2. Transcribe with word-level timestamps
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
if (!data.words || data.words.length === 0) {
alert('No se detectaron palabras en el audio.');
return;
}
// 3. Create captions layer
const captionLayerId = 'layer-captions-' + Date.now();
// 4. Generate caption elements
const captionElements = generateCaptionElements(
data.words,
30, // fps
el.startFrame,
captionLayerId,
style,
);
// 5. Add layer and elements
setTimelineElements(prev => [...prev, ...captionElements]);
setShowCaptionPicker(false);
} catch (err) {
console.error('Auto-caption error:', err);
alert('Error al generar auto-captions.');
} finally {
setIsGeneratingCaptions(false);
}
}}
/>
</div>
);
};
@@ -0,0 +1,73 @@
import React, { RefObject } from 'react';
import { Music } from 'lucide-react';
import { TimelineElement } from '../../types';
import { PlayerRef } from '@remotion/player';
import { uploadMedia } from '../../utils/mediaUploader';
import { FileDropZone } from '../ui/FileDropZone';
import { getAudioDuration, durationToFrames } from '../../utils/audioMetadata';
interface AudioLayerPanelProps {
activeLayerId: string;
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
timelineElements: TimelineElement[];
playerRef: RefObject<PlayerRef | null>;
endFrameLimit?: number;
}
export const AudioLayerPanel: React.FC<AudioLayerPanelProps> = ({
activeLayerId,
setTimelineElements,
timelineElements,
playerRef,
endFrameLimit = 150
}) => {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1">
<Music size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Capa de Audio
</h2>
<p className="text-[11px] text-neutral-400">Añade o edita pistas de audio</p>
</div>
<div className="p-5 flex-1 space-y-6 overflow-y-auto custom-scrollbar">
<div>
<label className="block text-xs font-medium text-neutral-300 mb-2">Añadir Audio (MP3/WAV)</label>
<FileDropZone
accept="audio/*"
label="Subir Audio"
sublabel="MP3, WAV o M4A — o arrastra aquí"
onFiles={async (files) => {
const file = files[0];
if (!file || !playerRef.current) return;
try {
const result = await uploadMedia(file);
const currentFrame = playerRef.current.getCurrentFrame() || 0;
// Get real audio duration
let endFrame = Math.min(endFrameLimit, currentFrame + 150);
try {
const dur = await getAudioDuration(result.url);
endFrame = currentFrame + durationToFrames(dur);
} catch {}
setTimelineElements(prev => [...prev, {
id: Date.now().toString(),
layerId: activeLayerId,
type: 'audio',
content: result.url,
startFrame: currentFrame,
endFrame,
x: 0,
y: 0,
originalFileName: result.originalName,
}]);
} catch (err) {
console.error('Audio upload failed:', err);
}
}}
/>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,362 @@
import React, { useCallback, RefObject } from 'react';
import { Film, Play, Camera, Download, Grid3x3, Palette, Maximize } from 'lucide-react';
import { CollapsibleSection } from '../ui/CollapsibleSection';
import { PlayerRef } from '@remotion/player';
import { EXPORT_PRESETS } from '../../config/constants';
import { TimelineElement, TimelineLayer } from '../../types';
import { ProjectStats } from '../ui/ProjectStats';
import { QuickElementTemplates } from '../ui/QuickElementTemplates';
import { BulkActionsBar } from '../ui/BulkActionsBar';
interface GlobalSettingsPanelProps {
textOverlay: string;
setTextOverlay: (text: string) => void;
playerRef?: RefObject<PlayerRef | null>;
aspectRatio?: '16:9' | '9:16' | '1:1' | '4:5' | '4:3';
outputFormat?: 'video' | 'image';
onExportClick?: () => void;
timelineElements?: TimelineElement[];
setTimelineElements?: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
showGrid?: boolean;
setShowGrid?: (show: boolean) => void;
showSafeZone?: boolean;
setShowSafeZone?: (show: boolean) => void;
onShowRenderHistory?: () => void;
layers?: TimelineLayer[];
durationInFrames?: number;
fps?: number;
}
export const GlobalSettingsPanel: React.FC<GlobalSettingsPanelProps> = ({
textOverlay, setTextOverlay, playerRef, aspectRatio, outputFormat, onExportClick,
timelineElements, setTimelineElements, showGrid, setShowGrid, showSafeZone, setShowSafeZone,
onShowRenderHistory, layers, durationInFrames, fps,
}) => {
// ═══ Export frame as PNG ═══
const handleExportFrame = useCallback(() => {
const player = playerRef?.current;
if (!player) return;
// Find the remotion-player container and grab its inner canvas/iframe
const playerContainer = document.querySelector('[data-remotion-player]') ?? document.querySelector('.remotion-player');
if (!playerContainer) {
// Fallback: find the iframe or video element
const iframe = document.querySelector('iframe');
if (iframe) {
// Can't capture cross-origin iframe, but for same-origin:
try {
const iframeDoc = iframe.contentDocument;
if (iframeDoc) {
const canvas = document.createElement('canvas');
const body = iframeDoc.body;
canvas.width = body.scrollWidth;
canvas.height = body.scrollHeight;
// This is a simplistic approach - Remotion doesn't expose easy screenshot
}
} catch { /* cross-origin */ }
}
}
// Use html2canvas-like approach: create a temporary canvas from the player
// For now, use a simple screenshot via the Remotion player's renderToCanvas
alert('📸 Exportar frame: Esta función requiere @remotion/renderer para renderizar frames individuales. Próximamente disponible.');
}, [playerRef]);
const matchingPresets = EXPORT_PRESETS.filter(p => p.aspect === aspectRatio);
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1"><Film size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Configuración Global</h2>
<p className="text-[11px] text-neutral-400">Parámetros base del render.</p>
</div>
<div className="p-5 flex-1 space-y-6 overflow-y-auto custom-scrollbar">
{/* Text Overlay */}
{/* Project Stats */}
{timelineElements && layers && durationInFrames && (
<div className="bg-neutral-950/30 border border-neutral-800/30 rounded-lg p-2.5">
<ProjectStats
timelineElements={timelineElements}
layers={layers}
durationInFrames={durationInFrames}
fps={fps ?? 30}
/>
</div>
)}
{/* ── Herramientas Avanzadas (collapsible) ── */}
<CollapsibleSection title="Herramientas">
{/* Quick Templates */}
{setTimelineElements && (
<div className="bg-neutral-950/30 border border-neutral-800/30 rounded-lg p-2.5">
<QuickElementTemplates
onAddElement={(partial) => {
const newEl: TimelineElement = {
id: 'el-' + Date.now(),
type: partial.type || 'text',
content: partial.content || '',
startFrame: 0,
endFrame: 150,
layerId: 'default',
...partial,
} as TimelineElement;
setTimelineElements(prev => [...prev, newEl]);
}}
/>
</div>
)}
{/* Bulk Actions */}
{timelineElements && setTimelineElements && (
<div className="bg-neutral-950/30 border border-neutral-800/30 rounded-lg p-2.5">
<BulkActionsBar
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
setSelectedElementId={() => {}}
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-neutral-300 mb-2">Pie de Mensaje Fijo</label>
<textarea
value={textOverlay}
onChange={(e) => setTextOverlay(e.target.value)}
rows={2}
placeholder="Mensaje inferior..."
className="bg-neutral-950 text-sm rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 resize-none"
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="Opciones de Fondo">
{/* Background Pattern Presets */}
{timelineElements && setTimelineElements && (
<div>
<span className="text-[9px] text-neutral-500 block mb-1">Patrones</span>
<div className="grid grid-cols-5 gap-1">
{[
{ name: 'Ninguno', bg: 'none', preview: '⊘' },
{ name: 'Puntos', bg: 'radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)', preview: '⋯' },
{ name: 'Líneas', bg: 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,0.08) 10px, rgba(255,255,255,0.08) 12px)', preview: '' },
{ name: 'Cuadrícula', bg: 'repeating-linear-gradient(0deg, rgba(255,255,255,0.06) 0px, rgba(255,255,255,0.06) 1px, transparent 1px, transparent 20px), repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0px, rgba(255,255,255,0.06) 1px, transparent 1px, transparent 20px)', preview: '⊞' },
{ name: 'Damero', bg: 'repeating-conic-gradient(rgba(255,255,255,0.05) 0% 25%, transparent 0% 50%) 0 0 / 20px 20px', preview: '⊟' },
].map(pattern => (
<button
key={pattern.name}
onClick={() => {
if (!setTimelineElements) return;
setTimelineElements(prev => prev.map(el => {
if (el.type !== 'color') return el;
return { ...el, backgroundPattern: pattern.bg === 'none' ? undefined : pattern.bg };
}));
}}
title={pattern.name}
className="py-1 rounded text-[9px] font-medium bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-violet-300 hover:border-violet-500/30 transition-all"
>
{pattern.preview}
</button>
))}
</div>
</div>
)}
{/* Background Image Upload */}
{setTimelineElements && (
<div>
<span className="text-[9px] text-neutral-500 block mb-1">Imagen de fondo</span>
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-neutral-700 hover:border-violet-500 cursor-pointer transition-colors text-[10px] text-neutral-400 hover:text-violet-300">
<Camera size={12} />
Subir imagen de fondo
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file || !setTimelineElements) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
const bgEl: TimelineElement = {
id: crypto.randomUUID(),
layerId: 'background',
type: 'sticker',
content: data.url,
startFrame: 0,
endFrame: 9999,
x: 50, y: 50,
width: 100,
objectFit: 'cover',
};
setTimelineElements(prev => [bgEl, ...prev]);
}
} catch (err) {
console.error('Upload failed:', err);
}
e.target.value = '';
}}
/>
</label>
</div>
)}
</CollapsibleSection>
<CollapsibleSection title="Vista">
{/* Grid Toggle */}
{setShowGrid && (
<div className="flex items-center justify-between py-1">
<label className="text-xs font-medium text-neutral-300 flex items-center gap-1.5">
<Grid3x3 size={12} className="text-neutral-500" />
Cuadrícula
</label>
<button
onClick={() => setShowGrid(!showGrid)}
title={showGrid ? "Ocultar cuadrícula" : "Mostrar cuadrícula"}
className={`px-3 py-1 rounded-md text-[10px] font-medium transition-all border ${
showGrid
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-neutral-300'
}`}
>
{showGrid ? 'ON' : 'OFF'}
</button>
</div>
)}
{/* Safe Zone Toggle */}
{setShowSafeZone && (
<div className="flex items-center justify-between py-1">
<label className="text-xs font-medium text-neutral-300 flex items-center gap-1.5">
<Maximize size={12} className="text-neutral-500" />
Zona Segura
</label>
<button
onClick={() => setShowSafeZone(!showSafeZone)}
title={showSafeZone ? "Ocultar zona segura" : "Mostrar zona segura"}
className={`px-3 py-1 rounded-md text-[10px] font-medium transition-all border ${
showSafeZone
? 'bg-pink-500/20 border-pink-500/50 text-pink-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-neutral-300'
}`}
>
{showSafeZone ? 'ON' : 'OFF'}
</button>
</div>
)}
</CollapsibleSection>
</div>
{/* Project Stats */}
{timelineElements && (
<div className="px-5 py-3 border-t border-neutral-800/40">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider block mb-2">Estadísticas</span>
<div className="grid grid-cols-5 gap-1">
{[
{ type: 'text', icon: '📝', color: '#a78bfa' },
{ type: 'image', icon: '🖼️', color: '#34d399' },
{ type: 'video', icon: '🎬', color: '#f472b6' },
{ type: 'audio', icon: '🎵', color: '#38bdf8' },
{ type: 'sticker', icon: '⭐', color: '#fbbf24' },
].map(item => {
const count = timelineElements.filter(e => e.type === item.type && !e.isBrandElement).length;
return (
<div key={item.type} className="flex flex-col items-center gap-0.5 py-1 rounded bg-neutral-900/50">
<span className="text-xs">{item.icon}</span>
<span className="text-[10px] font-bold" style={{ color: item.color }}>{count}</span>
</div>
);
})}
</div>
<div className="flex justify-between text-[9px] text-neutral-600 font-mono mt-1">
<span>Total: {timelineElements.filter(e => !e.isBrandElement).length} elementos</span>
<span>{aspectRatio ?? '9:16'}</span>
</div>
</div>
)}
<div className="p-5 border-t border-neutral-800 shrink-0 space-y-2">
{/* Capture frame button */}
<button
title="Exportar Frame como PNG"
onClick={onExportClick}
className="w-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white font-medium py-2 rounded-lg transition-colors flex items-center justify-center gap-2 text-xs border border-neutral-700"
>
<Camera size={14} /> Capturar Frame
</button>
{/* Render button */}
<button
title="Exportar Video"
onClick={onExportClick}
className="w-full bg-violet-600 hover:bg-violet-500 text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 text-sm shadow-xl shadow-violet-900/20"
>
<Play size={16} fill="currentColor" /> Renderizar
</button>
{/* Project Save/Load */}
<div className="flex gap-1.5 mt-1">
<button
title="Guardar proyecto como JSON"
onClick={() => {
const data = JSON.stringify({
version: 1,
aspectRatio,
timelineElements: timelineElements.filter(e => !e.isBrandElement),
exportedAt: new Date().toISOString(),
}, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `project-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-[10px] border border-neutral-700"
>
💾 Guardar
</button>
<button
title="Cargar proyecto desde JSON"
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (data.timelineElements && Array.isArray(data.timelineElements)) {
setTimelineElements(prev => {
const brandElements = prev.filter(el => el.isBrandElement);
return [...brandElements, ...data.timelineElements];
});
}
} catch { /* silently fail */ }
};
input.click();
}}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-[10px] border border-neutral-700"
>
📂 Cargar
</button>
</div>
{/* Render History */}
{onShowRenderHistory && (
<button
onClick={onShowRenderHistory}
title="Ver historial de renders"
className="w-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-[10px] border border-neutral-700 mt-1"
>
📋 Historial de Renders
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { Layers, Image as ImageIcon } from 'lucide-react';
export const GraphicLayerPanel: React.FC = () => {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1">
<Layers size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Capa Gráfica
</h2>
<p className="text-[11px] text-neutral-400">Añade textos o imágenes a esta capa</p>
</div>
<div className="p-5 flex-1 space-y-6 overflow-y-auto custom-scrollbar">
<div className="text-center text-neutral-500 text-sm py-10 px-4">
<ImageIcon size={24} className="mx-auto mb-3 opacity-50" />
Selecciona un elemento en la línea temporal para editarlo, o usa la barra de herramientas para añadir uno nuevo.
</div>
</div>
</div>
);
};
@@ -0,0 +1,345 @@
import React, { useState, useCallback } from 'react';
import { Eye, EyeOff, Lock, Unlock, Trash2, Type, Image as ImageIcon, Palette, Film, GripVertical, Copy, Layers } from 'lucide-react';
import { TimelineElement, TimelineLayer } from '../../types';
interface ImageLayersPanelProps {
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
layers: TimelineLayer[];
selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void;
}
const ELEMENT_ICONS: Record<string, React.ReactNode> = {
text: <Type size={16} />,
image: <ImageIcon size={16} />,
sticker: <ImageIcon size={16} />,
video: <Film size={16} />,
color: <Palette size={16} />,
};
const TYPE_LABELS: Record<string, string> = {
text: 'TEXTO',
image: 'IMAGEN',
sticker: 'STICKER',
video: 'VIDEO',
color: 'COLOR',
};
/**
* Photoshop-style layers panel for image editing mode.
* Features: drag reorder, visibility, opacity, lock, duplicate, delete.
* Elements are shown in reverse order (top-most layer first).
*/
export const ImageLayersPanel: React.FC<ImageLayersPanelProps> = ({
timelineElements,
setTimelineElements,
layers,
selectedElementId,
setSelectedElementId,
}) => {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null);
const [editingOpacityId, setEditingOpacityId] = useState<string | null>(null);
// Reversed for display: top-most layer (last in array) shown first
const sortedElements = [...timelineElements].reverse();
// ─── Actions ──────────────────────────────
const toggleVisibility = (id: string) => {
setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, opacity: (el.opacity === 0 ? 1 : 0) } : el
));
};
const toggleLock = (id: string) => {
setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, isLocked: !el.isLocked } : el
));
};
const setOpacity = (id: string, value: number) => {
setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, opacity: value } : el
));
};
const duplicateElement = (id: string) => {
setTimelineElements(prev => {
const el = prev.find(e => e.id === id);
if (!el) return prev;
const copy: TimelineElement = {
...el,
id: 'el-' + Date.now(),
x: el.x + 3,
y: el.y + 3,
isBrandElement: false,
};
const idx = prev.findIndex(e => e.id === id);
const next = [...prev];
next.splice(idx + 1, 0, copy);
return next;
});
};
const deleteElement = (id: string) => {
const el = timelineElements.find(e => e.id === id);
if (el?.isBrandElement) return;
setTimelineElements(prev => prev.filter(el => el.id !== id));
if (selectedElementId === id) setSelectedElementId(null);
};
// ─── Drag & Drop Reorder ──────────────────────────────
const handleDragStart = useCallback((e: React.DragEvent, visualIndex: number) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(visualIndex));
setDragFromIndex(visualIndex);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, visualIndex: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(visualIndex);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetVisualIndex: number) => {
e.preventDefault();
const fromVisual = parseInt(e.dataTransfer.getData('text/plain'));
if (isNaN(fromVisual) || fromVisual === targetVisualIndex) {
setDragOverIndex(null);
setDragFromIndex(null);
return;
}
// Convert visual indices (reversed) to actual array indices
const fromActual = timelineElements.length - 1 - fromVisual;
const toActual = timelineElements.length - 1 - targetVisualIndex;
setTimelineElements(prev => {
const next = [...prev];
const [moved] = next.splice(fromActual, 1);
next.splice(toActual, 0, moved);
return next;
});
setDragOverIndex(null);
setDragFromIndex(null);
}, [timelineElements.length, setTimelineElements]);
const handleDragEnd = useCallback(() => {
setDragOverIndex(null);
setDragFromIndex(null);
}, []);
// ─── Helpers ──────────────────────────────
const getLabel = (el: TimelineElement): string => {
if (el.type === 'text') {
const preview = el.content.slice(0, 24);
return preview.length < el.content.length ? `${preview}` : preview;
}
return TYPE_LABELS[el.type] || el.type;
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-2.5 border-b border-neutral-800 shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers size={12} className="text-neutral-500" />
<h3 className="text-[10px] font-bold tracking-widest text-neutral-400 uppercase">Capas</h3>
</div>
<span className="text-[10px] text-neutral-600 font-mono">{sortedElements.length}</span>
</div>
{/* Layers list */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{sortedElements.length === 0 ? (
<div className="p-8 text-center text-neutral-600">
<Layers size={24} className="mx-auto mb-2 opacity-30" />
<p className="text-[11px] font-medium">Sin elementos</p>
<p className="text-[10px] mt-1 text-neutral-700">Usa las herramientas para agregar capas</p>
</div>
) : (
sortedElements.map((el, visualIndex) => {
const isSelected = selectedElementId === el.id;
const isHidden = el.opacity === 0;
const isLocked = el.isLocked || el.isBrandElement;
const isDragOver = dragOverIndex === visualIndex;
const isDragging = dragFromIndex === visualIndex;
const showOpacity = editingOpacityId === el.id;
const opacityValue = el.opacity ?? 1;
return (
<div key={el.id}>
{/* Drop indicator */}
{isDragOver && !isDragging && (
<div className="h-0.5 bg-violet-500 mx-2 rounded-full shadow-[0_0_6px_rgba(139,92,246,0.6)]" />
)}
<div
draggable={!isLocked}
onDragStart={(e) => handleDragStart(e, visualIndex)}
onDragOver={(e) => handleDragOver(e, visualIndex)}
onDrop={(e) => handleDrop(e, visualIndex)}
onDragEnd={handleDragEnd}
onClick={() => setSelectedElementId(el.id)}
className={`group flex items-stretch border-b border-neutral-800/30 transition-all cursor-pointer ${
isSelected
? 'bg-violet-950/50 border-l-2 border-l-violet-500'
: 'bg-transparent hover:bg-neutral-800/30 border-l-2 border-l-transparent'
} ${isDragging ? 'opacity-30' : ''} ${isHidden ? 'opacity-50' : ''}`}
>
{/* Drag grip */}
<div className={`w-5 flex items-center justify-center shrink-0 ${isLocked ? 'cursor-not-allowed' : 'cursor-grab active:cursor-grabbing'}`}>
<GripVertical size={10} className="text-neutral-700 group-hover:text-neutral-500 transition-colors" />
</div>
{/* Visibility toggle */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleVisibility(el.id); }}
title={isHidden ? 'Mostrar' : 'Ocultar'}
className="w-6 flex items-center justify-center shrink-0 text-neutral-600 hover:text-white transition-colors"
>
{isHidden ? <EyeOff size={11} /> : <Eye size={11} className={isSelected ? 'text-violet-400' : ''} />}
</button>
{/* Thumbnail */}
<div className={`w-10 h-10 my-1 rounded flex items-center justify-center shrink-0 overflow-hidden ${
isSelected ? 'ring-1 ring-violet-500/50' : ''
}`}>
{(el.type === 'image' || el.type === 'sticker') ? (
<img
src={el.content}
alt=""
className="w-full h-full object-cover rounded"
draggable={false}
/>
) : el.type === 'video' ? (
<div className="w-full h-full bg-sky-950/50 rounded flex items-center justify-center">
<Film size={14} className="text-sky-400" />
</div>
) : el.type === 'color' ? (
<div className="w-full h-full rounded" style={{ backgroundColor: el.content || '#000' }} />
) : (
<div className={`w-full h-full rounded flex items-center justify-center ${
isSelected ? 'bg-violet-950/50' : 'bg-neutral-900'
}`}>
<span className={isSelected ? 'text-violet-400' : 'text-neutral-600'}>
{ELEMENT_ICONS[el.type] || <ImageIcon size={14} />}
</span>
</div>
)}
</div>
{/* Label + type + opacity */}
<div className="flex-1 min-w-0 py-1.5 pl-2 flex flex-col justify-center">
<p className={`text-[11px] font-medium truncate leading-tight ${isSelected ? 'text-white' : 'text-neutral-300'}`}>
{getLabel(el)}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[8px] text-neutral-600 uppercase tracking-wider font-semibold">
{TYPE_LABELS[el.type] || el.type}
</span>
{opacityValue < 1 && opacityValue > 0 && (
<span className="text-[8px] text-neutral-600 font-mono">
{Math.round(opacityValue * 100)}%
</span>
)}
</div>
</div>
{/* Actions */}
<div className={`flex items-center gap-0 px-1 shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
{/* Lock */}
{el.isBrandElement ? (
<span className="w-5 h-5 flex items-center justify-center text-amber-500" title="Marca (protegido)">
<Lock size={10} />
</span>
) : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleLock(el.id); }}
title={el.isLocked ? 'Desbloquear' : 'Bloquear'}
className={`w-5 h-5 flex items-center justify-center rounded transition-colors ${
el.isLocked ? 'text-amber-400' : 'text-neutral-600 hover:text-neutral-300'
}`}
>
{el.isLocked ? <Lock size={10} /> : <Unlock size={10} />}
</button>
)}
{/* Opacity toggle */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); setEditingOpacityId(showOpacity ? null : el.id); }}
title="Opacidad"
className={`w-5 h-5 flex items-center justify-center rounded text-[9px] font-mono transition-colors ${
showOpacity ? 'text-violet-400' : 'text-neutral-600 hover:text-neutral-300'
}`}
>
α
</button>
{/* Duplicate */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); duplicateElement(el.id); }}
title="Duplicar capa"
className="w-5 h-5 flex items-center justify-center rounded text-neutral-600 hover:text-white transition-colors"
>
<Copy size={10} />
</button>
{/* Delete */}
{!el.isBrandElement && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); deleteElement(el.id); }}
title="Eliminar capa"
className="w-5 h-5 flex items-center justify-center rounded text-neutral-600 hover:text-rose-400 transition-colors"
>
<Trash2 size={10} />
</button>
)}
</div>
</div>
{/* Inline opacity slider */}
{showOpacity && (
<div className="px-4 py-2 bg-neutral-950/60 border-b border-neutral-800/30 flex items-center gap-3">
<span className="text-[9px] text-neutral-500 w-12 shrink-0">Opacidad</span>
<input
type="range"
min="0" max="1" step="0.01"
value={opacityValue}
onChange={(e) => setOpacity(el.id, parseFloat(e.target.value))}
onClick={(e) => e.stopPropagation()}
className="flex-1 h-1 accent-violet-500"
/>
<span className="text-[9px] text-neutral-500 font-mono w-8 text-right">
{Math.round(opacityValue * 100)}%
</span>
</div>
)}
</div>
);
})
)}
</div>
{/* Footer info */}
<div className="px-3 py-2 border-t border-neutral-800 shrink-0 flex items-center justify-between">
<span className="text-[9px] text-neutral-600">
Arrastra para reordenar
</span>
<span className="text-[9px] text-neutral-700 font-mono">
z-index
</span>
</div>
</div>
);
};
@@ -0,0 +1,142 @@
import React from 'react';
import { Layers, Trash2, Lock, Eye, EyeOff, Copy, Move } from 'lucide-react';
import { TimelineElement } from '../../types';
interface MultiSelectActionsProps {
selectedIds: Set<string>;
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
clearSelection: () => void;
}
/**
* MultiSelectActions — Shown when 2+ elements are selected.
* Provides bulk operations on the selected set: delete, lock, hide, duplicate, align.
*/
export const MultiSelectActions: React.FC<MultiSelectActionsProps> = ({
selectedIds,
timelineElements,
setTimelineElements,
clearSelection,
}) => {
const selectedElements = timelineElements.filter(e => selectedIds.has(e.id));
const count = selectedElements.length;
if (count < 2) return null;
const allLocked = selectedElements.every(e => e.isLocked);
const allHidden = selectedElements.every(e => e.isHidden);
return (
<div className="p-4 space-y-3">
{/* Header */}
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-violet-500/10 border border-violet-500/20">
<Layers size={16} className="text-violet-400" />
</div>
<div>
<h3 className="text-xs font-bold text-white">{count} elementos seleccionados</h3>
<p className="text-[9px] text-neutral-500">Shift+Click para añadir/quitar</p>
</div>
</div>
{/* Selected list */}
<div className="space-y-1 max-h-24 overflow-y-auto custom-scrollbar">
{selectedElements.map(el => (
<div key={el.id} className="flex items-center gap-1.5 px-2 py-0.5 rounded bg-neutral-900/50 border border-violet-500/20">
<span className="text-[8px] text-violet-400">{el.type}</span>
<span className="text-[8px] text-neutral-300 truncate flex-1">
{el.elementName || el.content?.slice(0, 20) || el.type}
</span>
</div>
))}
</div>
{/* Bulk actions */}
<div className="grid grid-cols-2 gap-1.5">
<button
onClick={() => {
setTimelineElements(prev => prev.map(e =>
selectedIds.has(e.id) ? { ...e, isLocked: !allLocked } : e
));
}}
title={allLocked ? "Desbloquear seleccionados" : "Bloquear seleccionados"}
className="flex items-center justify-center gap-1 py-1.5 rounded-lg bg-neutral-900 border border-neutral-800 text-[9px] text-neutral-400 hover:text-amber-300 hover:border-amber-500/30 transition-colors"
>
<Lock size={10} /> {allLocked ? 'Desbloquear' : 'Bloquear'}
</button>
<button
onClick={() => {
setTimelineElements(prev => prev.map(e =>
selectedIds.has(e.id) ? { ...e, isHidden: !allHidden } : e
));
}}
title={allHidden ? "Mostrar seleccionados" : "Ocultar seleccionados"}
className="flex items-center justify-center gap-1 py-1.5 rounded-lg bg-neutral-900 border border-neutral-800 text-[9px] text-neutral-400 hover:text-sky-300 hover:border-sky-500/30 transition-colors"
>
{allHidden ? <Eye size={10} /> : <EyeOff size={10} />}
{allHidden ? 'Mostrar' : 'Ocultar'}
</button>
<button
onClick={() => {
const copies = selectedElements
.filter(e => !e.isBrandElement)
.map(e => ({
...e,
id: 'el-' + Date.now() + '-' + Math.random().toString(36).slice(2, 5),
x: (e.x ?? 50) + 2,
y: (e.y ?? 50) + 2,
isBrandElement: false,
}));
setTimelineElements(prev => [...prev, ...copies]);
}}
title="Duplicar seleccionados"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg bg-neutral-900 border border-neutral-800 text-[9px] text-neutral-400 hover:text-violet-300 hover:border-violet-500/30 transition-colors"
>
<Copy size={10} /> Duplicar
</button>
<button
onClick={() => {
if (!confirm(`¿Eliminar ${count} elementos?`)) return;
setTimelineElements(prev => prev.filter(e => !selectedIds.has(e.id)));
clearSelection();
}}
title="Eliminar seleccionados"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg bg-neutral-900 border border-neutral-800 text-[9px] text-neutral-400 hover:text-red-300 hover:border-red-500/30 transition-colors"
>
<Trash2 size={10} /> Eliminar
</button>
</div>
{/* Quick align */}
<div className="flex items-center gap-1">
<span className="text-[8px] text-neutral-500 mr-1">Alinear:</span>
{[
{ label: 'Izq', x: 10 },
{ label: 'Centro', x: 50 },
{ label: 'Der', x: 90 },
].map(pos => (
<button
key={pos.label}
onClick={() => {
setTimelineElements(prev => prev.map(e =>
selectedIds.has(e.id) ? { ...e, x: pos.x } : e
));
}}
title={`Alinear ${pos.label}`}
className="px-1.5 py-0.5 rounded text-[7px] bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-white hover:border-neutral-600 transition-colors"
>
{pos.label}
</button>
))}
</div>
<button
onClick={clearSelection}
className="w-full py-1 rounded-lg text-[9px] text-neutral-500 hover:text-white hover:bg-neutral-800 transition-colors"
>
Deseleccionar todo (Esc)
</button>
</div>
);
};
@@ -0,0 +1,44 @@
import React from 'react';
import { Wand2 } from 'lucide-react';
import { DesignMD } from '../../types';
interface TransitionsPanelProps {
designMD: DesignMD;
}
export const TransitionsPanel: React.FC<TransitionsPanelProps> = ({ designMD }) => {
const allowedTransitions = ['none'];
if (designMD.defaultTransitionIn && designMD.defaultTransitionIn !== 'none') allowedTransitions.push(designMD.defaultTransitionIn);
if (designMD.defaultTransitionOut && designMD.defaultTransitionOut !== 'none' && !allowedTransitions.includes(designMD.defaultTransitionOut)) allowedTransitions.push(designMD.defaultTransitionOut);
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1">
<Wand2 size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Transiciones (Brand)
</h2>
<p className="text-[11px] text-neutral-400">Transiciones aprobadas por {designMD.baseFont ? 'la marca' : 'el manual'}</p>
</div>
<div className="p-5 flex-1 space-y-4 overflow-y-auto custom-scrollbar">
{allowedTransitions.map(type => (
<div
key={type}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/json', JSON.stringify({ type }));
}}
className="bg-neutral-900 border border-neutral-700 hover:border-violet-500 rounded-lg p-3 text-neutral-300 hover:text-white cursor-grab active:cursor-grabbing flex items-center justify-between shadow-sm transition-all"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-violet-900/30 text-violet-400 flex items-center justify-center">
<Wand2 size={14} />
</div>
<span className="text-xs font-semibold uppercase">{type === 'none' ? 'quitar' : type}</span>
</div>
<span className="text-[9px] bg-violet-900 text-violet-300 px-1.5 py-0.5 rounded uppercase font-bold tracking-widest text-right">{type === 'none' ? '' : 'Brand Kit'}</span>
</div>
))}
</div>
</div>
);
};
+394
View File
@@ -0,0 +1,394 @@
import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
import { Play, Pause, RotateCcw, Film } from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player';
import { ExpressTemplate, CompanyProfile, DesignMD } from '../../types';
import { BrandComposition } from '../BrandComposition';
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
/**
* LivePreviewCanvas — Shared Remotion preview component.
*
* Compiles TemplateField[] + fieldData → TimelineElement[] → Remotion Player.
*
* Used in:
* - ProductionForm (production preview)
* - TemplateBuilder test-data mode (design-time preview)
*/
export interface LivePreviewCanvasProps {
template: ExpressTemplate;
fieldData: Record<string, string>;
brand: CompanyProfile;
designMD: DesignMD;
/** Override objectFit per field ID */
mediaFits?: Record<string, 'cover' | 'contain' | 'fill'>;
/** Override containBgColor per field ID */
containBgColors?: Record<string, string | null>;
/** Show playback controls (play/pause/reset) — default true for video */
showControls?: boolean;
/** Active scene ID for scene navigation */
activeSceneId?: string | null;
/** Callback when user navigates to a scene */
onSceneChange?: (sceneId: string) => void;
/** External player ref */
playerRef?: React.RefObject<PlayerRef>;
/** Status label (e.g. "Listo" / "Faltan campos") */
statusLabel?: string;
/** Whether all required fields are complete */
isComplete?: boolean;
}
/** Format frame number to mm:ss */
function formatTime(frames: number, fps: number): string {
const secs = Math.floor(frames / fps);
const mins = Math.floor(secs / 60);
const remainSecs = secs % 60;
return `${mins}:${String(remainSecs).padStart(2, '0')}`;
}
/** Scene type colors for timeline segments */
const SCENE_COLORS: Record<string, string> = {
intro: '#10b981',
content: '#8b5cf6',
outro: '#f43f5e',
};
export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
template,
fieldData,
brand,
designMD,
mediaFits = {},
containBgColors = {},
showControls,
activeSceneId,
onSceneChange,
playerRef: externalRef,
statusLabel,
isComplete = false,
}) => {
const internalRef = useRef<PlayerRef>(null);
const playerRef = externalRef || internalRef;
const [isPlaying, setIsPlaying] = useState(false);
const [currentFrame, setCurrentFrame] = useState(0);
const scrubRef = useRef<HTMLDivElement>(null);
const isScrubbing = useRef(false);
const fps = 30;
const totalDuration = getTemplateDuration(template);
const totalFrames = Math.max(30, totalDuration * fps);
const dimensions = getAspectDimensions(template.aspectRatio);
// Compile template to timeline (reactive to fieldData + mediaFits)
const compiled = useMemo(() => {
const result = compileExpressToTimeline(template, fieldData, designMD, brand);
// Strip transitions and apply mediaFit overrides
result.elements = result.elements.map(el => {
const fieldId = el.sourceFieldId;
const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
const bgOverride = fieldId ? containBgColors[fieldId] : undefined;
return {
...el,
transitionIn: undefined,
transitionOut: undefined,
...(fitOverride ? { objectFit: fitOverride } : {}),
...(bgOverride !== undefined ? { containBgColor: bgOverride } : {}),
};
});
return result;
}, [template, fieldData, designMD, brand, mediaFits, containBgColors]);
const playerInputProps = useMemo(() => ({
designMD,
timelineElements: compiled.elements,
layers: compiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: {
logo: false,
frame: false,
background: true,
},
outputFormat: template.format,
}), [designMD, compiled, template.format]);
// Force Player remount when media sources change (blob URLs from uploads).
// Remotion's Player doesn't always re-render paused compositions on inputProps change.
const playerKey = useMemo(() => {
return compiled.elements
.filter(el => el.type === 'video' || el.type === 'image')
.map(el => el.content || '')
.join('|');
}, [compiled]);
const shouldShowControls = showControls ?? (template.format === 'video');
const isMultiScene = template.scenes.length > 1;
// ── Frame tracking via polling ──
useEffect(() => {
if (!shouldShowControls) return;
const interval = setInterval(() => {
if (playerRef.current && !isScrubbing.current) {
const frame = playerRef.current.getCurrentFrame();
setCurrentFrame(frame);
}
}, 1000 / 15); // 15Hz polling is enough for UI update
return () => clearInterval(interval);
}, [playerRef, shouldShowControls]);
// ── Scene segments for timeline ──
const sceneSegments = useMemo(() => {
let offset = 0;
return template.scenes.map(scene => {
const durFrames = scene.durationSeconds * fps;
const seg = {
id: scene.id,
name: scene.type === 'intro' ? 'INTRO' : scene.type === 'outro' ? 'OUTRO' : scene.name,
type: scene.type || 'content',
startFrame: offset,
endFrame: offset + durFrames,
widthPct: (durFrames / totalFrames) * 100,
};
offset += durFrames;
return seg;
});
}, [template, fps, totalFrames]);
const handlePlayToggle = useCallback(() => {
if (playerRef.current) {
if (isPlaying) {
playerRef.current.pause();
} else {
playerRef.current.play();
}
setIsPlaying(!isPlaying);
}
}, [isPlaying, playerRef]);
const handleSelectScene = useCallback((sceneId: string) => {
if (!playerRef.current) return;
let frameOffset = 0;
for (const scene of template.scenes) {
if (scene.id === sceneId) break;
frameOffset += scene.durationSeconds * fps;
}
playerRef.current.seekTo(frameOffset);
playerRef.current.pause();
setIsPlaying(false);
onSceneChange?.(sceneId);
}, [template, fps, playerRef, onSceneChange]);
// ── Scrub bar interactions ──
const scrubToPosition = useCallback((clientX: number) => {
if (!scrubRef.current || !playerRef.current) return;
const rect = scrubRef.current.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const frame = Math.round(pct * (totalFrames - 1));
playerRef.current.seekTo(frame);
setCurrentFrame(frame);
}, [playerRef, totalFrames]);
const handleScrubDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
isScrubbing.current = true;
playerRef.current?.pause();
setIsPlaying(false);
scrubToPosition(e.clientX);
}, [playerRef, scrubToPosition]);
const handleScrubMove = useCallback((e: React.PointerEvent) => {
if (!isScrubbing.current) return;
scrubToPosition(e.clientX);
}, [scrubToPosition]);
const handleScrubUp = useCallback(() => {
isScrubbing.current = false;
}, []);
const playheadPct = totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0;
// Determine which scene the playhead is currently in
const activeSceneFromPlayhead = useMemo(() => {
for (const seg of sceneSegments) {
if (currentFrame >= seg.startFrame && currentFrame < seg.endFrame) return seg.id;
}
return sceneSegments[sceneSegments.length - 1]?.id;
}, [currentFrame, sceneSegments]);
return (
<div className="flex-1 flex flex-col items-center justify-center relative z-10 overflow-hidden">
{/* Status header */}
{statusLabel !== undefined && (
<div className="absolute top-4 left-5 flex items-center gap-2 z-20">
<div className={`w-2 h-2 rounded-full ${isComplete ? 'bg-emerald-400' : 'bg-amber-400 animate-pulse'}`} />
<span className="text-xs font-semibold text-neutral-300">Preview en vivo</span>
<span className={`text-[9px] px-2 py-0.5 rounded-full font-medium ${
isComplete
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-amber-500/10 text-amber-400'
}`}>
{statusLabel}
</span>
</div>
)}
{/* Player container */}
<div
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/60 border border-neutral-800/40"
style={{
width: template.aspectRatio === '9:16' ? 240
: template.aspectRatio === '1:1' ? 320
: template.aspectRatio === '4:5' ? 280
: 420,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100% - 160px)',
}}
>
<Player
key={playerKey}
ref={playerRef}
component={BrandComposition}
inputProps={playerInputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={fps}
style={{ width: '100%', height: '100%' }}
controls={false}
autoPlay={false}
loop
/>
</div>
{/* ═══ Timeline Controls ═══ */}
{shouldShowControls && (
<div className="mt-4 w-full max-w-md px-4 z-10 space-y-2">
{/* ── Scrub Bar with scene segments ── */}
<div
ref={scrubRef}
className="relative h-7 cursor-col-resize group rounded-lg overflow-hidden"
onPointerDown={handleScrubDown}
onPointerMove={handleScrubMove}
onPointerUp={handleScrubUp}
onPointerCancel={handleScrubUp}
title="Arrastra para navegar"
>
{/* Scene segment blocks */}
<div className="absolute inset-0 flex gap-px rounded-lg overflow-hidden">
{sceneSegments.map(seg => {
const isActive = activeSceneFromPlayhead === seg.id;
const color = SCENE_COLORS[seg.type] || SCENE_COLORS.content;
return (
<div
key={seg.id}
className="relative h-full flex items-center justify-center transition-all"
style={{
width: `${seg.widthPct}%`,
backgroundColor: isActive ? `${color}25` : `${color}10`,
borderBottom: `2px solid ${isActive ? color : `${color}40`}`,
}}
>
<span
className="text-[7px] font-bold tracking-wider truncate px-1 pointer-events-none select-none"
style={{ color: isActive ? color : `${color}80` }}
>
{seg.name}
</span>
</div>
);
})}
</div>
{/* Progress fill */}
<div
className="absolute top-0 left-0 h-full pointer-events-none transition-[width] duration-75"
style={{
width: `${playheadPct}%`,
background: 'linear-gradient(90deg, rgba(139,92,246,0.1), rgba(139,92,246,0.05))',
}}
/>
{/* Playhead line */}
<div
className="absolute top-0 h-full w-0.5 pointer-events-none transition-[left] duration-75 z-10"
style={{
left: `${playheadPct}%`,
background: 'rgba(255,255,255,0.9)',
boxShadow: '0 0 4px rgba(139,92,246,0.6)',
}}
/>
{/* Playhead thumb (appears on hover / scrub) */}
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full bg-white shadow-lg shadow-violet-500/30 border-2 border-violet-500 pointer-events-none z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
style={{ left: `${playheadPct}%` }}
/>
</div>
{/* ── Controls row ── */}
<div className="flex items-center gap-2">
<button
onClick={handlePlayToggle}
title={isPlaying ? 'Pausar' : 'Reproducir'}
className="w-8 h-8 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-md"
>
{isPlaying ? <Pause size={12} fill="currentColor" /> : <Play size={12} fill="currentColor" />}
</button>
<button
onClick={() => { playerRef.current?.seekTo(0); setCurrentFrame(0); setIsPlaying(false); playerRef.current?.pause(); }}
title="Reiniciar"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
>
<RotateCcw size={10} />
</button>
{/* Time display */}
<span className="text-[10px] font-mono text-neutral-400 ml-1">
{formatTime(currentFrame, fps)}
<span className="text-neutral-600 mx-0.5">/</span>
{formatTime(totalFrames, fps)}
</span>
<div className="flex-1" />
{/* Scene navigation buttons */}
{isMultiScene && (
<div className="flex items-center gap-0.5">
{template.scenes.map(scene => {
const color = SCENE_COLORS[scene.type || 'content'] || SCENE_COLORS.content;
const isActive = activeSceneFromPlayhead === scene.id;
const label = scene.type === 'intro' ? 'IN'
: scene.type === 'outro' ? 'OUT'
: scene.name.slice(0, 4);
return (
<button
key={scene.id}
onClick={() => handleSelectScene(scene.id)}
title={`${scene.type === 'intro' ? 'Intro' : scene.type === 'outro' ? 'Outro' : scene.name}${scene.durationSeconds}s`}
className="px-2 py-0.5 rounded text-[7px] font-bold border transition-all uppercase tracking-wider"
style={{
borderColor: isActive ? `${color}80` : 'rgba(64,64,64,0.5)',
backgroundColor: isActive ? `${color}15` : 'transparent',
color: isActive ? color : 'rgb(115,115,115)',
}}
>
{label}
</button>
);
})}
</div>
)}
</div>
</div>
)}
{/* Hint */}
<p className="absolute bottom-4 text-[10px] text-neutral-600 z-10">
Se actualiza al llenar los campos
</p>
</div>
);
};
@@ -0,0 +1,276 @@
import React, { useRef } from 'react';
import {
Type, Image as ImageIcon, Video, Upload, AlertCircle,
Maximize2, Minimize2, Move, Pipette, X,
} from 'lucide-react';
import { TemplateField, DesignMD } from '../../types';
/**
* TemplateFieldInput — Shared form field component for TemplateField.
*
* Used in:
* - ProductionForm (live mode — user fills real data)
* - FormPreviewPanel (disabled mode — shows form mockup in builder)
* - TemplateBuilder test-data mode (live — designer fills test data)
*/
export interface TemplateFieldInputProps {
field: TemplateField;
value: string;
onChange: (value: string) => void;
error?: string;
designMD: DesignMD;
/** Media fit mode for image/video fields */
mediaFit?: 'cover' | 'contain' | 'fill';
/** Callback when user changes the media fit mode */
onMediaFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
/** Background color for contain mode empty space (null = transparent) */
containBgColor?: string | null;
/** Callback when user changes the contain background color */
onContainBgColorChange?: (color: string | null) => void;
/** When true, all inputs are disabled (form preview mode) */
disabled?: boolean;
}
export const TemplateFieldInput: React.FC<TemplateFieldInputProps> = ({
field,
value,
onChange,
error,
designMD,
mediaFit,
onMediaFitChange,
containBgColor,
onContainBgColorChange,
disabled = false,
}) => {
const colorInputRef = useRef<HTMLInputElement>(null);
const isText = field.type === 'text';
const isMedia = field.type === 'image' || field.type === 'video';
const isVideoField = field.type === 'video';
const isMultiline = field.rules?.multiline;
const maxChars = field.rules?.maxChars;
const resolvedFont = (() => {
if (field.style?.textRole === 'title') return designMD.titleFont || designMD.baseFont;
if (field.style?.textRole === 'subtitle') return designMD.subtitleFont || designMD.baseFont;
return designMD.paragraphFont || designMD.baseFont;
})();
const currentFit = mediaFit || 'cover';
return (
<div className="space-y-1.5">
{/* Label */}
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300 font-medium">
{isText && <Type size={11} className="text-sky-400" />}
{isMedia && (isVideoField
? <Video size={11} className="text-sky-400" />
: <ImageIcon size={11} className="text-sky-400" />
)}
{field.label}
{field.required && <span className="text-red-400 text-[10px]">*</span>}
</label>
{/* Text input */}
{isText && (
isMultiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.content || `Escribe ${field.label.toLowerCase()}...`}
rows={3}
maxLength={maxChars || undefined}
disabled={disabled}
className={`w-full bg-neutral-800/50 border rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 resize-none focus:outline-none focus:border-violet-500/50 transition-colors ${
disabled ? 'text-neutral-500 cursor-not-allowed' : ''
} ${
error ? 'border-red-500/50' : 'border-neutral-700'
}`}
style={{ fontFamily: resolvedFont }}
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.content || `Escribe ${field.label.toLowerCase()}...`}
maxLength={maxChars || undefined}
disabled={disabled}
className={`w-full bg-neutral-800/50 border rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-colors ${
disabled ? 'text-neutral-500 cursor-not-allowed' : ''
} ${
error ? 'border-red-500/50' : 'border-neutral-700'
}`}
style={{
fontFamily: resolvedFont,
fontWeight: field.style.fontWeight || 400,
}}
/>
)
)}
{/* Media upload */}
{isMedia && (
<label
className={`flex flex-col items-center justify-center h-24 border-2 border-dashed rounded-lg transition-colors ${
disabled ? 'cursor-default' : 'cursor-pointer'
} ${
value
? 'border-violet-500/30 bg-violet-950/10'
: error
? 'border-red-500/30 bg-red-950/5'
: 'border-neutral-700 bg-neutral-800/30 hover:border-neutral-600'
}`}
>
{value ? (
<div className="relative w-full h-full">
{isVideoField ? (
<video
src={value}
muted
autoPlay
loop
playsInline
className="w-full h-full object-cover rounded-md"
/>
) : (
<img
src={value}
alt={field.label}
className="w-full h-full object-cover rounded-md"
/>
)}
{!disabled && (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onChange(''); }}
title="Quitar media"
className="absolute top-1 right-1 bg-black/70 text-white text-[8px] px-1.5 py-0.5 rounded hover:bg-red-600 transition-colors"
>
</button>
)}
</div>
) : (
<>
<Upload size={18} className="text-neutral-600 mb-1.5" />
<span className="text-[9px] text-neutral-600">
{isVideoField ? 'Subir video' : 'Subir imagen'}
</span>
{field.rules?.aspectRatio && (
<span className="text-[8px] text-neutral-700 mt-0.5">
Ratio: {field.rules.aspectRatio}
</span>
)}
</>
)}
{!disabled && (
<input
type="file"
accept={isVideoField ? 'video/*' : 'image/*'}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
onChange(url);
}
}}
/>
)}
</label>
)}
{/* Media Fit Selector — shown when media is uploaded and not disabled */}
{isMedia && value && !disabled && onMediaFitChange && (
<div className="flex items-center gap-1">
<span className="text-[8px] text-neutral-500 mr-1">Ajuste:</span>
{([
{ key: 'cover' as const, label: 'Cover', icon: <Maximize2 size={9} />, tip: 'Llena el área, recorta bordes' },
{ key: 'contain' as const, label: 'Contain', icon: <Minimize2 size={9} />, tip: 'Muestra completo, puede tener vacíos' },
{ key: 'fill' as const, label: 'Fill', icon: <Move size={9} />, tip: 'Estira para llenar (puede distorsionar)' },
]).map(opt => (
<button
key={opt.key}
type="button"
title={opt.tip}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMediaFitChange(opt.key); }}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-medium border transition-all ${
currentFit === opt.key
? 'border-violet-500/50 bg-violet-500/15 text-violet-300'
: 'border-neutral-800 bg-neutral-900 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
)}
{/* Contain Background Color Picker — shown when fit=contain, media uploaded, not disabled */}
{isMedia && value && !disabled && currentFit === 'contain' && onContainBgColorChange && (
<div className="flex items-center gap-1.5">
<span className="text-[8px] text-neutral-500">Fondo:</span>
{/* Color swatch — click opens native picker */}
<button
type="button"
title={containBgColor ? `Color: ${containBgColor}` : 'Seleccionar color de fondo'}
onClick={(e) => { e.preventDefault(); colorInputRef.current?.click(); }}
className="w-5 h-5 rounded border border-neutral-700 hover:border-neutral-500 transition-colors overflow-hidden flex items-center justify-center shrink-0"
style={{
backgroundColor: containBgColor || undefined,
// Checkerboard pattern for transparent
...(!containBgColor ? {
backgroundImage: 'linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%)',
backgroundSize: '6px 6px',
backgroundPosition: '0 0, 0 3px, 3px -3px, -3px 0px',
} : {}),
}}
>
{!containBgColor && <Pipette size={8} className="text-neutral-400" />}
</button>
<input
ref={colorInputRef}
type="color"
value={containBgColor || '#000000'}
onChange={(e) => onContainBgColorChange(e.target.value)}
className="sr-only"
tabIndex={-1}
/>
{/* Transparent toggle */}
<button
type="button"
title="Fondo transparente"
onClick={(e) => { e.preventDefault(); onContainBgColorChange(null); }}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[8px] font-medium border transition-all ${
!containBgColor
? 'border-violet-500/50 bg-violet-500/15 text-violet-300'
: 'border-neutral-800 bg-neutral-900 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
<X size={7} />
Transparente
</button>
{/* Quick color — show clear button when color is set */}
{containBgColor && (
<span className="text-[7px] text-neutral-600 font-mono">{containBgColor}</span>
)}
</div>
)}
{/* Error / validation hints */}
{error && (
<p className="text-[9px] text-red-400 flex items-center gap-1">
<AlertCircle size={9} /> {error}
</p>
)}
{!error && maxChars && isText && (
<p className="text-[8px] text-neutral-600 flex items-center gap-1">
<AlertCircle size={8} /> Máximo {maxChars} caracteres
{value && <span className="ml-auto">{value.length}/{maxChars}</span>}
</p>
)}
</div>
);
};
+443
View File
@@ -0,0 +1,443 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { ExportModal } from '../export/ExportModal';
import { StudioToolbar, PanelType } from '../StudioToolbar';
import { StudioWorkspace } from '../StudioWorkspace';
import { CanvasZoomControls } from '../ui/CanvasZoomControls';
import { PlaybackInfo } from '../ui/PlaybackInfo';
import { StudioProperties } from '../StudioProperties';
import { StudioTimeline } from '../StudioTimeline';
import { MediaLibraryPanel } from '../MediaLibraryPanel';
import { TextPanel } from '../panels/TextPanel';
import { StickersPanel } from '../panels/StickersPanel';
import { AudioPanel } from '../panels/AudioPanel';
import { ShapesPanel } from '../panels/ShapesPanel';
import { SoundEffectsPanel } from '../panels/SoundEffectsPanel';
import { ShortcutsOverlay } from '../ui/ShortcutsOverlay';
import { RenderHistoryPanel } from '../ui/RenderHistoryPanel';
import { ElementSearch } from '../ui/ElementSearch';
import { TimelineMarkerList, TimelineMarker } from '../timeline/TimelineMarkerList';
import { ResponsivePreviewToggle } from '../ui/ResponsivePreviewToggle';
import { AutoSaveIndicator } from '../ui/AutoSaveIndicator';
import { CanvasGridOverlay } from '../ui/CanvasGridOverlay';
import { useEditor } from '../../context/EditorContext';
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
import { useCanvasShortcuts } from '../../hooks/useCanvasShortcuts';
import { RenderProps, TimelineElement } from '../../types';
/**
* StudioEditor: The main editing view.
* Reads all state from EditorContext — no prop drilling needed.
*/
export const StudioEditor: React.FC = () => {
const {
timelineElements, setTimelineElements,
layers, setLayers,
selectedElementId, setSelectedElementId,
activeLayerId, setActiveLayerId,
activeTool, setActiveTool,
designMD,
textOverlay, setTextOverlay,
playerRef,
outputFormat,
aspectRatio, setAspectRatio,
timelineZoom, setTimelineZoom,
timeUnit, setTimeUnit,
durationInFrames,
canvasZoom, setCanvasZoom,
undo, redo,
brandContent,
brandVisibility, setBrandVisibility,
activeAction, setActiveAction,
selectedElementIds, toggleElementSelection, clearSelection,
} = useEditor();
// Panel state (replaces old activeTool for toolbar)
const [activePanel, setActivePanel] = useState<PanelType>(null);
const [showExportModal, setShowExportModal] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [showRenderHistory, setShowRenderHistory] = useState(false);
const [showElementSearch, setShowElementSearch] = useState(false);
const [markers, setMarkers] = useState<TimelineMarker[]>([]);
const [previewMode, setPreviewMode] = useState<'desktop' | 'tablet' | 'phone' | null>(null);
const [showGrid, setShowGrid] = useState(false);
const [showSafeZone, setShowSafeZone] = useState(false);
// ═══ Auto-save to localStorage ═══
const AUTOSAVE_KEY = 'studio-autosave';
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
const [lastSaved, setLastSaved] = useState<number | null>(null);
// Auto-save after 2s of inactivity
useEffect(() => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(() => {
try {
const data = {
timelineElements: timelineElements.filter(e => !e.isBrandElement),
aspectRatio,
markers,
savedAt: Date.now(),
};
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
setLastSaved(Date.now());
} catch { /* quota exceeded — silently fail */ }
}, 2000);
return () => { if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); };
}, [timelineElements, aspectRatio, markers]);
// Auto-load on mount (only if no elements exist)
useEffect(() => {
try {
const saved = localStorage.getItem(AUTOSAVE_KEY);
if (!saved) return;
const data = JSON.parse(saved);
if (data.timelineElements?.length && timelineElements.filter(e => !e.isBrandElement).length === 0) {
setTimelineElements(prev => {
const brand = prev.filter(e => e.isBrandElement);
return [...brand, ...data.timelineElements];
});
if (data.markers) setMarkers(data.markers);
}
} catch { /* corrupted data — ignore */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Canvas zoom keyboard shortcuts (Cmd+=/Cmd+-/Cmd+0)
useCanvasShortcuts(setCanvasZoom);
// ? key toggles shortcuts overlay, Cmd+F toggles element search
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target as HTMLElement).isContentEditable
) return;
if (e.key === '?' || (e.shiftKey && e.code === 'Slash')) {
e.preventDefault();
setShowShortcuts(prev => !prev);
}
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault();
setShowElementSearch(prev => !prev);
}
// G = toggle grid, Shift+S = toggle safe zone
if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
setShowGrid(prev => !prev);
}
if (e.key === 'S' && e.shiftKey && !e.metaKey && !e.ctrlKey) {
setShowSafeZone(prev => !prev);
}
// Escape clears selection
if (e.key === 'Escape') {
clearSelection();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
// Keyboard shortcuts
useKeyboardShortcuts({
enabled: true,
playerRef,
durationInFrames,
selectedElementId,
setSelectedElementId,
timelineElements,
setTimelineElements,
undo,
redo,
});
// --- Memoized callbacks for composition ---
const handleElementClick = useCallback((id: string) => {
setSelectedElementId(id);
const element = timelineElements.find(el => el.id === id);
// In image mode, auto-switch active layer to the element's layer
if (element && outputFormat === 'image') {
setActiveLayerId(element.layerId);
}
if (element && playerRef.current) {
const currentFrame = playerRef.current.getCurrentFrame();
if (currentFrame < element.startFrame || currentFrame >= element.endFrame) {
playerRef.current.seekTo(element.startFrame);
}
}
}, [timelineElements, playerRef, setSelectedElementId, outputFormat, setActiveLayerId]);
const handlePositionChange = useCallback((id: string, x: number, y: number) => {
setTimelineElements(prev => prev.map(el => el.id === id ? { ...el, x, y } : el));
}, [setTimelineElements]);
const handleTransformChange = useCallback((id: string, updates: Partial<TimelineElement>) => {
setTimelineElements(prev => prev.map(el => el.id === id ? { ...el, ...updates } : el));
}, [setTimelineElements]);
const handleDuplicate = useCallback((id: string) => {
setTimelineElements(prev => {
const el = prev.find(e => e.id === id);
if (!el) return prev;
const copy: TimelineElement = {
...el,
id: 'el-' + Date.now(),
x: el.x + 3,
y: el.y + 3,
isBrandElement: false,
isLocked: false,
};
const idx = prev.findIndex(e => e.id === id);
const next = [...prev];
next.splice(idx + 1, 0, copy);
return next;
});
}, [setTimelineElements]);
const handleDelete = useCallback((id: string) => {
setTimelineElements(prev => {
const el = prev.find(e => e.id === id);
if (el?.isBrandElement) return prev;
return prev.filter(e => e.id !== id);
});
setSelectedElementId(null);
}, [setTimelineElements, setSelectedElementId]);
const handleLock = useCallback((id: string) => {
setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, isLocked: !el.isLocked } : el
));
}, [setTimelineElements]);
// --- Composition Props (memoized) ---
const compositionProps: RenderProps = useMemo(() => ({
designMD,
textOverlay,
layers,
timelineElements: timelineElements
.filter(el => {
const layer = layers.find(l => l.id === el.layerId);
return layer ? layer.isVisible !== false : true;
})
.sort((a, b) => {
const aIsActive = a.layerId === activeLayerId ? 1 : 0;
const bIsActive = b.layerId === activeLayerId ? 1 : 0;
if (aIsActive !== bIsActive) return aIsActive - bIsActive;
const indexA = layers.findIndex(l => l.id === a.layerId);
const indexB = layers.findIndex(l => l.id === b.layerId);
return indexB - indexA;
}),
selectedElementId,
activeLayerId,
onElementClick: handleElementClick,
onElementPositionChange: handlePositionChange,
onElementTransformChange: handleTransformChange,
onElementDuplicate: handleDuplicate,
onElementDelete: handleDelete,
onElementLock: handleLock,
activeAction,
brandVisibility,
outputFormat,
}), [designMD, textOverlay, layers, timelineElements, selectedElementId, activeLayerId, activeAction, brandVisibility, outputFormat, handleElementClick, handlePositionChange, handleTransformChange, handleDuplicate, handleDelete, handleLock]);
return (
<>
<div className="flex-1 flex flex-col w-full overflow-hidden">
<div className="flex-1 flex overflow-hidden">
<StudioToolbar
activePanel={activePanel}
setActivePanel={setActivePanel}
onShowShortcuts={() => setShowShortcuts(true)}
outputFormat={outputFormat}
/>
{/* Sliding Panels */}
{activePanel === 'media' && (
<MediaLibraryPanel
onClose={() => setActivePanel(null)}
designMD={designMD}
brandContent={brandContent}
/>
)}
{activePanel === 'text' && (
<TextPanel onClose={() => setActivePanel(null)} />
)}
{activePanel === 'stickers' && (
<StickersPanel onClose={() => setActivePanel(null)} />
)}
{activePanel === 'shapes' && (
<ShapesPanel onClose={() => setActivePanel(null)} />
)}
{activePanel === 'audio' && (
<AudioPanel onClose={() => setActivePanel(null)} />
)}
{activePanel === 'sfx' && (
<SoundEffectsPanel onClose={() => setActivePanel(null)} />
)}
<div className="relative flex-1 flex flex-col min-h-0">
<StudioWorkspace
activeTool={activeTool}
setSelectedElementId={setSelectedElementId}
selectedElementId={selectedElementId}
playerRef={playerRef}
compositionProps={compositionProps}
durationInFrames={durationInFrames}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
aspectRatio={aspectRatio}
setAspectRatio={setAspectRatio}
outputFormat={outputFormat}
activeLayerId={activeLayerId}
zoom={canvasZoom}
setZoom={setCanvasZoom}
/>
<CanvasZoomControls
zoom={canvasZoom}
onZoomIn={() => setCanvasZoom(prev => Math.min(5, prev + 0.25))}
onZoomOut={() => setCanvasZoom(prev => Math.max(0.1, prev - 0.25))}
onZoomReset={() => setCanvasZoom(1)}
onFitToScreen={() => setCanvasZoom(1)}
onUndo={undo}
onRedo={redo}
onSetZoom={setCanvasZoom}
/>
<PlaybackInfo
playerRef={playerRef}
durationInFrames={durationInFrames}
elementCount={timelineElements.length}
/>
{/* Responsive Preview Toggle + Grid/SafeZone toggles */}
<div className="absolute top-3 left-3 z-20 flex items-center gap-1">
<ResponsivePreviewToggle mode={previewMode} onModeChange={setPreviewMode} />
<div className="flex items-center gap-0.5 bg-neutral-900/80 backdrop-blur-sm border border-neutral-800/40 rounded-lg p-0.5">
<button
onClick={() => setShowGrid(!showGrid)}
title={showGrid ? 'Ocultar grilla' : 'Mostrar grilla (regla de tercios)'}
className={`p-1 rounded-md transition-all text-[10px] ${
showGrid ? 'bg-violet-500/20 text-violet-300' : 'text-neutral-600 hover:text-neutral-400'
}`}
>
</button>
<button
onClick={() => setShowSafeZone(!showSafeZone)}
title={showSafeZone ? 'Ocultar zona segura' : 'Mostrar zona segura (broadcast)'}
className={`p-1 rounded-md transition-all text-[10px] ${
showSafeZone ? 'bg-amber-500/20 text-amber-300' : 'text-neutral-600 hover:text-neutral-400'
}`}
>
</button>
</div>
</div>
{/* Canvas Grid + Safe Zone Overlay */}
<CanvasGridOverlay showGrid={showGrid} showSafeZone={showSafeZone} width={1080} height={1080} />
{/* Auto-save indicator */}
<AutoSaveIndicator lastSaved={lastSaved} />
</div>
<StudioProperties
designMD={designMD}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
layers={layers}
activeLayerId={activeLayerId}
timeUnit={timeUnit}
textOverlay={textOverlay}
setTextOverlay={setTextOverlay}
playerRef={playerRef}
activeTool={activeTool}
outputFormat={outputFormat}
onExportClick={() => setShowExportModal(true)}
onShowRenderHistory={() => setShowRenderHistory(true)}
showGrid={showGrid}
setShowGrid={setShowGrid}
showSafeZone={showSafeZone}
setShowSafeZone={setShowSafeZone}
selectedElementIds={selectedElementIds}
clearSelection={clearSelection}
/>
</div>
{outputFormat !== 'image' && (
<div className="flex flex-col">
<StudioTimeline
timelineZoom={timelineZoom}
setTimelineZoom={setTimelineZoom}
timeUnit={timeUnit}
setTimeUnit={setTimeUnit}
durationInFrames={durationInFrames}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
layers={layers}
setLayers={setLayers}
activeLayerId={activeLayerId}
setActiveLayerId={setActiveLayerId}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
playerRef={playerRef}
activeTool={activeTool}
outputFormat={outputFormat}
designMD={designMD}
selectedElementIds={selectedElementIds}
toggleElementSelection={toggleElementSelection}
/>
<TimelineMarkerList
markers={markers}
setMarkers={setMarkers}
currentFrame={playerRef.current?.getCurrentFrame?.() ?? 0}
durationInFrames={durationInFrames}
fps={30}
onSeekToFrame={(frame) => playerRef.current?.seekTo(frame)}
/>
</div>
)}
</div>
{/* Export Modal */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
designMD={designMD}
textOverlay={textOverlay}
timelineElements={timelineElements}
layers={layers}
durationInFrames={durationInFrames}
brandVisibility={brandVisibility}
outputFormat={outputFormat}
/>
{/* Shortcuts Overlay */}
<ShortcutsOverlay
isOpen={showShortcuts}
onClose={() => setShowShortcuts(false)}
/>
{/* Render History */}
<RenderHistoryPanel
isOpen={showRenderHistory}
onClose={() => setShowRenderHistory(false)}
/>
{/* Element Search (Cmd+F toggle) */}
{showElementSearch && (
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-50 w-72 bg-neutral-900 border border-neutral-700 rounded-xl shadow-2xl p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-white">🔍 Buscar Elementos</span>
<button onClick={() => setShowElementSearch(false)} title="Cerrar" className="text-neutral-500 hover:text-white text-xs"></button>
</div>
<ElementSearch
timelineElements={timelineElements}
selectedElementId={selectedElementId}
onSelectElement={(id) => { setSelectedElementId(id); setShowElementSearch(false); }}
/>
</div>
)}
</>
);
};
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import { TopHeader } from '../TopHeader';
import { useEditor } from '../../context/EditorContext';
interface StudioTopBarProps {
setCurrentStep: (step: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form') => void;
}
/**
* Wrapper that connects TopHeader to EditorContext for zoom/ratio controls.
* Lives inside EditorProvider so it can access canvas zoom state.
*/
export const StudioTopBar: React.FC<StudioTopBarProps> = ({ setCurrentStep }) => {
const { canvasZoom, setCanvasZoom, aspectRatio, setAspectRatio, outputFormat } = useEditor();
return (
<TopHeader
currentStep="studio"
setCurrentStep={setCurrentStep}
outputFormat={outputFormat}
zoom={canvasZoom}
onZoomIn={() => setCanvasZoom(prev => Math.min(5, prev + 0.25))}
onZoomOut={() => setCanvasZoom(prev => Math.max(0.1, prev - 0.25))}
onZoomReset={() => setCanvasZoom(1)}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
/>
);
};
+165
View File
@@ -0,0 +1,165 @@
import React from 'react';
import { Sparkles } from 'lucide-react';
import { TimelineElement } from '../../types';
interface TextAnimationPreset {
name: string;
emoji: string;
apply: (el: TimelineElement) => Partial<TimelineElement>;
}
const TEXT_PRESETS: TextAnimationPreset[] = [
{
name: 'Título Grande',
emoji: '📢',
apply: () => ({
fontSize: 72,
fontWeight: 900,
color: '#ffffff',
textAlign: 'center' as const,
transitionIn: { type: 'scale' as const, duration: 15 },
transitionOut: { type: 'fade' as const, duration: 10 },
}),
},
{
name: 'Subtítulo',
emoji: '💬',
apply: () => ({
fontSize: 32,
fontWeight: 400,
color: '#e0e0e0',
textAlign: 'center' as const,
textBackground: '#000000AA',
textBackgroundPadding: 10,
textBackgroundRadius: 6,
transitionIn: { type: 'fade' as const, duration: 10 },
transitionOut: { type: 'fade' as const, duration: 10 },
}),
},
{
name: 'Neon',
emoji: '✨',
apply: () => ({
fontSize: 56,
fontWeight: 700,
color: '#00ffaa',
textAlign: 'center' as const,
shadowOffset: 0,
shadowBlur: 20,
transitionIn: { type: 'scale' as const, duration: 12 },
}),
},
{
name: 'CTA / Callout',
emoji: '👉',
apply: () => ({
fontSize: 42,
fontWeight: 800,
color: '#ffffff',
textAlign: 'center' as const,
textBackground: '#7c3aedDD',
textBackgroundPadding: 14,
textBackgroundRadius: 12,
transitionIn: { type: 'slideRight' as const, duration: 15 },
transitionOut: { type: 'slideRight' as const, duration: 12 },
}),
},
{
name: 'Lower Third',
emoji: '📋',
apply: () => ({
fontSize: 28,
fontWeight: 600,
color: '#ffffff',
textAlign: 'left' as const,
textBackground: '#000000CC',
textBackgroundPadding: 10,
textBackgroundRadius: 4,
x: 8,
y: 85,
transitionIn: { type: 'slideRight' as const, duration: 12 },
transitionOut: { type: 'slideRight' as const, duration: 10 },
}),
},
{
name: 'Tipo Machine',
emoji: '⌨️',
apply: () => ({
fontSize: 36,
fontWeight: 400,
fontFamily: 'Fira Code',
color: '#00ff88',
textAlign: 'left' as const,
textBackground: '#0a0a0aEE',
textBackgroundPadding: 16,
textBackgroundRadius: 8,
transitionIn: { type: 'fade' as const, duration: 20 },
}),
},
{
name: 'Pop',
emoji: '💥',
apply: () => ({
fontSize: 64,
fontWeight: 900,
color: '#FF6B6B',
textAlign: 'center' as const,
rotation: -3,
transitionIn: { type: 'scale' as const, duration: 8 },
transitionOut: { type: 'scale' as const, duration: 8 },
}),
},
{
name: 'Cine',
emoji: '🎬',
apply: () => ({
fontSize: 48,
fontWeight: 300,
fontFamily: 'Playfair Display',
color: '#f5f5dc',
textAlign: 'center' as const,
letterSpacing: 8,
transitionIn: { type: 'fade' as const, duration: 25 },
transitionOut: { type: 'fade' as const, duration: 25 },
}),
},
];
interface AnimatedTextPresetsProps {
element: TimelineElement;
onApplyPreset: (updates: Partial<TimelineElement>) => void;
}
/**
* AnimatedTextPresets — Grid of text style presets that apply animation + styling in one click.
*/
export const AnimatedTextPresets: React.FC<AnimatedTextPresetsProps> = ({
element,
onApplyPreset,
}) => {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Sparkles size={12} className="text-amber-400" />
<span className="text-[10px] font-semibold text-neutral-400 uppercase tracking-wider">Presets Animados</span>
</div>
<div className="grid grid-cols-2 gap-1.5">
{TEXT_PRESETS.map((preset) => (
<button
key={preset.name}
onClick={() => onApplyPreset(preset.apply(element))}
title={`Aplicar preset: ${preset.name}`}
className="px-2 py-2 rounded-lg border border-neutral-800 bg-neutral-950/50 hover:border-violet-500/40 hover:bg-violet-500/5 transition-all text-left group"
>
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-sm">{preset.emoji}</span>
<span className="text-[10px] font-medium text-neutral-300 group-hover:text-white transition-colors">
{preset.name}
</span>
</div>
</button>
))}
</div>
</div>
);
};
@@ -0,0 +1,191 @@
import React, { useRef, useCallback } from 'react';
import { TimelineElement } from '../../types';
interface AudioVolumeOverlayProps {
element: TimelineElement;
width: number;
height: number;
isSelected: boolean;
onUpdateElement: (updates: Partial<TimelineElement>) => void;
}
/**
* SVG overlay showing volume envelope (fade in/out + keyframes) on audio clips.
* Renders a yellow line showing the volume curve with draggable fade handles.
*/
export const AudioVolumeOverlay: React.FC<AudioVolumeOverlayProps> = ({
element,
width,
height,
isSelected,
onUpdateElement,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const clipDuration = element.endFrame - element.startFrame;
if (clipDuration <= 0 || width <= 0) return null;
const fadeIn = element.fadeInFrames ?? 0;
const fadeOut = element.fadeOutFrames ?? 0;
const baseVolume = element.volume ?? 1;
const keyframes = element.volumeKeyframes ?? [];
// Build envelope path points
const points: { x: number; y: number }[] = [];
// Start at 0 volume if fade in
if (fadeIn > 0) {
points.push({ x: 0, y: height });
points.push({ x: (fadeIn / clipDuration) * width, y: height * (1 - baseVolume) });
} else {
points.push({ x: 0, y: height * (1 - baseVolume) });
}
// Volume keyframes (interpolate between them)
if (keyframes.length > 0) {
const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
for (const kf of sorted) {
const x = (kf.frame / clipDuration) * width;
const y = height * (1 - kf.volume);
points.push({ x, y });
}
}
// End with fade out
if (fadeOut > 0) {
const fadeOutStart = ((clipDuration - fadeOut) / clipDuration) * width;
// If there are no keyframes after the current last point at this x, add the base volume
const lastPoint = points[points.length - 1];
if (lastPoint.x < fadeOutStart) {
points.push({ x: fadeOutStart, y: height * (1 - baseVolume) });
}
points.push({ x: width, y: height });
} else {
const lastPoint = points[points.length - 1];
if (lastPoint.x < width) {
points.push({ x: width, y: height * (1 - baseVolume) });
}
}
// Build SVG path
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
// Fill area under the curve
const fillD = `${pathD} L ${width} ${height} L 0 ${height} Z`;
// Fade handle drag
const handleFadeDrag = useCallback((type: 'in' | 'out', e: React.PointerEvent) => {
if (!isSelected) return;
e.stopPropagation();
e.preventDefault();
const startX = e.clientX;
const startFrames = type === 'in' ? (element.fadeInFrames ?? 0) : (element.fadeOutFrames ?? 0);
const svgRect = svgRef.current?.getBoundingClientRect();
if (!svgRect) return;
const pxPerFrame = svgRect.width / clipDuration;
const onMove = (me: PointerEvent) => {
const deltaPx = type === 'in' ? me.clientX - startX : startX - me.clientX;
const deltaFrames = Math.round(deltaPx / pxPerFrame);
const newFrames = Math.max(0, Math.min(Math.floor(clipDuration / 2), startFrames + deltaFrames));
if (type === 'in') {
onUpdateElement({ fadeInFrames: newFrames > 0 ? newFrames : undefined });
} else {
onUpdateElement({ fadeOutFrames: newFrames > 0 ? newFrames : undefined });
}
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}, [isSelected, element, clipDuration, onUpdateElement]);
const fadeInX = fadeIn > 0 ? (fadeIn / clipDuration) * width : 0;
const fadeOutX = fadeOut > 0 ? ((clipDuration - fadeOut) / clipDuration) * width : width;
return (
<svg
ref={svgRef}
className="absolute inset-0 pointer-events-none"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
style={{ width: '100%', height: '100%' }}
>
{/* Fill under curve */}
<path
d={fillD}
fill="rgba(250, 204, 21, 0.08)"
/>
{/* Volume envelope line */}
<path
d={pathD}
fill="none"
stroke={isSelected ? 'rgba(250, 204, 21, 0.9)' : 'rgba(250, 204, 21, 0.4)'}
strokeWidth={isSelected ? 1.5 : 1}
vectorEffect="non-scaling-stroke"
/>
{/* Fade In handle */}
{isSelected && (
<circle
cx={fadeInX}
cy={fadeIn > 0 ? height * (1 - baseVolume) : height * (1 - baseVolume)}
r={4}
fill="#fbbf24"
stroke="#78350f"
strokeWidth={1}
className="pointer-events-auto cursor-ew-resize"
onPointerDown={(e) => handleFadeDrag('in', e)}
style={{ filter: 'drop-shadow(0 0 3px rgba(251, 191, 36, 0.5))' }}
>
<title>Arrastrar para ajustar Fade In</title>
</circle>
)}
{/* Fade Out handle */}
{isSelected && (
<circle
cx={fadeOutX}
cy={fadeOut > 0 ? height * (1 - baseVolume) : height * (1 - baseVolume)}
r={4}
fill="#fbbf24"
stroke="#78350f"
strokeWidth={1}
className="pointer-events-auto cursor-ew-resize"
onPointerDown={(e) => handleFadeDrag('out', e)}
style={{ filter: 'drop-shadow(0 0 3px rgba(251, 191, 36, 0.5))' }}
>
<title>Arrastrar para ajustar Fade Out</title>
</circle>
)}
{/* Volume keyframe diamonds */}
{isSelected && keyframes.map((kf, i) => {
const cx = (kf.frame / clipDuration) * width;
const cy = height * (1 - kf.volume);
return (
<g key={`vkf-${i}`} className="pointer-events-auto cursor-pointer">
<rect
x={cx - 3}
y={cy - 3}
width={6}
height={6}
fill="#fbbf24"
stroke="#fff"
strokeWidth={0.5}
transform={`rotate(45, ${cx}, ${cy})`}
style={{ filter: 'drop-shadow(0 0 2px rgba(251, 191, 36, 0.6))' }}
>
<title>{`Volume: ${Math.round(kf.volume * 100)}% @ frame ${kf.frame}`}</title>
</rect>
</g>
);
})}
</svg>
);
};
@@ -0,0 +1,92 @@
import React, { useRef, useEffect, useState } from 'react';
import { getPeaks } from '../../utils/audioWaveform';
interface AudioWaveformCanvasProps {
src: string;
width: number;
height: number;
color?: string;
bgColor?: string;
/** Number of peak buckets to render */
resolution?: number;
}
/**
* Canvas-based audio waveform visualization.
* Decodes the audio file and renders real peak data.
*/
export const AudioWaveformCanvas: React.FC<AudioWaveformCanvasProps> = ({
src,
width,
height,
color = 'rgba(129, 140, 248, 0.6)',
bgColor = 'transparent',
resolution = 150,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [peaks, setPeaks] = useState<Float32Array | null>(null);
// Load peaks when src changes
useEffect(() => {
let cancelled = false;
setPeaks(null);
if (src) {
getPeaks(src, resolution).then((data) => {
if (!cancelled) setPeaks(data);
});
}
return () => { cancelled = true; };
}, [src, resolution]);
// Draw waveform
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !peaks) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
// Clear
ctx.clearRect(0, 0, width, height);
if (bgColor !== 'transparent') {
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
}
// Draw waveform bars
const barWidth = width / peaks.length;
const centerY = height / 2;
ctx.fillStyle = color;
for (let i = 0; i < peaks.length; i++) {
const amp = peaks[i];
const barH = Math.max(1, amp * (height * 0.85));
const x = i * barWidth;
const y = centerY - barH / 2;
// Round to nearest pixel for crisp rendering
ctx.fillRect(
Math.round(x),
Math.round(y),
Math.max(1, Math.round(barWidth) - 1),
Math.round(barH)
);
}
}, [peaks, width, height, color, bgColor]);
return (
<canvas
ref={canvasRef}
style={{ width, height, display: 'block' }}
className="pointer-events-none"
/>
);
};
@@ -0,0 +1,115 @@
import React from 'react';
import { Copy, Trash2, Lock, Unlock, Scissors, Layers, ArrowUp, ArrowDown, Eye, EyeOff } from 'lucide-react';
import { TimelineElement } from '../../types';
interface ElementContextMenuProps {
elementId: string;
x: number;
y: number;
element: TimelineElement;
onClose: () => void;
onDuplicate: (id: string) => void;
onDelete: (id: string) => void;
onToggleLock: (id: string) => void;
onSplit: (id: string) => void;
onBringForward: (id: string) => void;
onSendBackward: (id: string) => void;
}
const MenuItem: React.FC<{
icon: React.ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}> = ({ icon, label, onClick, danger, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
title={label}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-[11px] rounded-md transition-colors ${
danger
? 'text-red-400 hover:bg-red-500/10'
: disabled
? 'text-neutral-600 cursor-not-allowed'
: 'text-neutral-300 hover:bg-neutral-800 hover:text-white'
}`}
>
{icon}
{label}
</button>
);
/**
* ElementContextMenu — right-click context menu for timeline elements.
* Actions: Duplicate, Split, Lock/Unlock, Move Forward/Backward, Delete.
*/
export const ElementContextMenu: React.FC<ElementContextMenuProps> = ({
elementId,
x,
y,
element,
onClose,
onDuplicate,
onDelete,
onToggleLock,
onSplit,
onBringForward,
onSendBackward,
}) => {
const isBrand = element.isBrandElement;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[60]"
onClick={onClose}
onContextMenu={(e) => { e.preventDefault(); onClose(); }}
/>
{/* Menu */}
<div
className="fixed z-[61] bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl shadow-black/60 py-1.5 px-1 min-w-[180px] animate-in"
style={{ left: x, top: y }}
>
<MenuItem
icon={<Copy size={13} />}
label="Duplicar"
onClick={() => { onDuplicate(elementId); onClose(); }}
disabled={isBrand}
/>
<MenuItem
icon={<Scissors size={13} />}
label="Dividir en Playhead"
onClick={() => { onSplit(elementId); onClose(); }}
disabled={isBrand}
/>
<div className="my-1 border-t border-neutral-800/50" />
<MenuItem
icon={element.isLocked ? <Unlock size={13} /> : <Lock size={13} />}
label={element.isLocked ? "Desbloquear" : "Bloquear"}
onClick={() => { onToggleLock(elementId); onClose(); }}
disabled={isBrand}
/>
<MenuItem
icon={<ArrowUp size={13} />}
label="Mover Adelante"
onClick={() => { onBringForward(elementId); onClose(); }}
/>
<MenuItem
icon={<ArrowDown size={13} />}
label="Mover Atrás"
onClick={() => { onSendBackward(elementId); onClose(); }}
/>
<div className="my-1 border-t border-neutral-800/50" />
<MenuItem
icon={<Trash2 size={13} />}
label="Eliminar"
onClick={() => { onDelete(elementId); onClose(); }}
danger
disabled={isBrand}
/>
</div>
</>
);
};
@@ -0,0 +1,88 @@
import React from 'react';
import { Lock, Unlock, Copy, Trash2 } from 'lucide-react';
import { TimelineLayer } from '../../types';
interface LayerContextMenuProps {
layerContextMenu: { layerId: string; x: number; y: number };
layers: TimelineLayer[];
setLayers: React.Dispatch<React.SetStateAction<TimelineLayer[]>>;
onToggleLock: (layerId: string) => void;
onDuplicate: (layerId: string) => void;
onDelete: (layerId: string) => void;
onClose: () => void;
}
export const LayerContextMenu: React.FC<LayerContextMenuProps> = ({
layerContextMenu,
layers,
setLayers,
onToggleLock,
onDuplicate,
onDelete,
onClose
}) => {
const currentLayer = layers.find(l => l.id === layerContextMenu.layerId);
return (
<div
className="fixed z-50 w-48 bg-[#111] border border-neutral-800 rounded-xl shadow-2xl py-1 backdrop-blur-xl"
style={{ top: layerContextMenu.y, left: layerContextMenu.x }}
onClick={e => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-800 hover:text-white flex items-center gap-2 transition-colors"
onClick={() => onToggleLock(layerContextMenu.layerId)}
>
{currentLayer?.isLocked ? <Unlock size={14} /> : <Lock size={14} />}
{currentLayer?.isLocked ? 'Desbloquear' : 'Bloquear'}
</button>
<button
className="w-full text-left px-3 py-2 text-xs font-medium text-neutral-300 hover:bg-neutral-800 hover:text-white flex items-center gap-2 transition-colors"
onClick={() => onDuplicate(layerContextMenu.layerId)}
>
<Copy size={14} /> Duplicar Capa
</button>
<div className="h-px bg-neutral-800 my-1 mx-2"></div>
<div className="px-3 py-1">
<span className="text-[10px] text-neutral-500 font-medium tracking-wide">Color de Etiqueta</span>
<div className="flex items-center gap-1.5 mt-1.5">
{[
{ color: 'none', class: 'bg-transparent border border-neutral-600 hover:border-white' },
{ color: 'red', class: 'bg-rose-500' },
{ color: 'orange', class: 'bg-orange-500' },
{ color: 'yellow', class: 'bg-yellow-500' },
{ color: 'green', class: 'bg-emerald-500' },
{ color: 'blue', class: 'bg-blue-500' },
{ color: 'purple', class: 'bg-violet-500' },
{ color: 'pink', class: 'bg-pink-500' },
].map(lbl => (
<button
key={lbl.color}
onClick={() => {
setLayers(layers.map(l => l.id === layerContextMenu.layerId ? { ...l, colorLabel: lbl.color === 'none' ? undefined : lbl.color } : l));
onClose();
}}
className={`w-3.5 h-3.5 rounded-full ${lbl.class} transition-all ${
(currentLayer?.colorLabel || 'none') === lbl.color
? 'ring-2 ring-white/50 ring-offset-1 ring-offset-[#111] scale-110'
: 'opacity-70 hover:opacity-100 hover:scale-110'
}`}
title={lbl.color}
/>
))}
</div>
</div>
<div className="h-px bg-neutral-800 my-1 mx-2"></div>
<button
className="w-full text-left px-3 py-2 text-xs font-medium text-rose-400 hover:bg-rose-500/10 hover:text-rose-300 flex items-center gap-2 transition-colors"
onClick={() => onDelete(layerContextMenu.layerId)}
>
<Trash2 size={14} /> Eliminar Capa
</button>
</div>
);
};
@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { Clock, Scissors, ZoomIn, ZoomOut, LayoutTemplate } from 'lucide-react';
import { SCENE_TEMPLATES, SceneTemplate } from '../../config/sceneTemplates';
interface TimelineControlsProps {
timelineZoom: number;
setTimelineZoom: (zoom: number) => void;
timeUnit: 'frames' | 'seconds';
setTimeUnit: (unit: 'frames' | 'seconds') => void;
durationInFrames: number;
selectedElementId: string | null;
onSplit: () => void;
outputFormat?: 'video' | 'image';
onInsertTemplate?: (template: SceneTemplate) => void;
}
export const TimelineControls: React.FC<TimelineControlsProps> = ({
timelineZoom,
setTimelineZoom,
timeUnit,
setTimeUnit,
durationInFrames,
selectedElementId,
onSplit,
outputFormat,
onInsertTemplate
}) => {
const [showTemplates, setShowTemplates] = useState(false);
const categories = [
{ key: 'titulo' as const, label: 'Títulos' },
{ key: 'contenido' as const, label: 'Contenido' },
{ key: 'cierre' as const, label: 'Cierres' },
{ key: 'transicion' as const, label: 'Transiciones' },
];
return (
<div className="border-b border-neutral-800 p-2 flex items-center justify-between bg-neutral-950/50">
<div className="flex items-center gap-2 px-2">
<Clock size={14} className="text-violet-400" />
<span className="text-xs font-bold text-white tracking-wide">TIMELINE</span>
</div>
{/* Timeline Controls */}
{outputFormat !== 'image' && (
<div className="flex items-center gap-4 pr-2">
<button
onClick={onSplit}
title="Cortar en cabezal"
disabled={!selectedElementId}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded bg-neutral-900 border border-neutral-800 transition-colors text-xs font-medium ${selectedElementId ? 'hover:bg-neutral-800 text-neutral-300' : 'text-neutral-600 cursor-not-allowed'}`}
>
<Scissors size={14} /> Cortar
</button>
{/* Scene Templates */}
{onInsertTemplate && (
<div className="relative">
<button
onClick={() => setShowTemplates(!showTemplates)}
title="Insertar plantilla de escena"
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-neutral-900 border border-neutral-800 hover:bg-neutral-800 transition-colors text-xs font-medium text-neutral-300"
>
<LayoutTemplate size={14} /> Plantillas
</button>
{showTemplates && (
<div className="absolute left-0 top-full mt-1 bg-neutral-900 border border-neutral-700 rounded-lg shadow-2xl z-50 min-w-[220px] max-h-80 overflow-y-auto custom-scrollbar">
{categories.map(cat => {
const templates = SCENE_TEMPLATES.filter(t => t.category === cat.key);
if (templates.length === 0) return null;
return (
<div key={cat.key}>
<div className="px-3 py-1 text-[9px] text-neutral-500 uppercase tracking-wider font-semibold bg-neutral-950/50 sticky top-0">
{cat.label}
</div>
{templates.map(tpl => (
<button
key={tpl.id}
onClick={() => { onInsertTemplate(tpl); setShowTemplates(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-neutral-300 hover:bg-neutral-800 transition-colors text-left"
title={tpl.description}
>
<span className="text-sm">{tpl.icon}</span>
<div className="flex-1">
<div className="font-medium">{tpl.name}</div>
<div className="text-[9px] text-neutral-500">{tpl.description}</div>
</div>
<span className="text-[9px] text-neutral-600 font-mono">{(tpl.durationFrames / 30).toFixed(1)}s</span>
</button>
))}
</div>
);
})}
</div>
)}
</div>
)}
<div className="flex items-center gap-2 bg-neutral-900 px-2 py-1 rounded border border-neutral-800">
<button
onClick={() => setTimelineZoom(Math.max(1, timelineZoom - 0.5))}
title="Alejar (Zoom Out)"
className="text-neutral-400 hover:text-white p-0.5 transition-colors rounded hover:bg-neutral-700"
>
<ZoomOut size={12} />
</button>
<span className="text-[10px] text-neutral-300 font-mono w-6 text-center" title="Nivel de Zoom">{timelineZoom}x</span>
<button
onClick={() => setTimelineZoom(Math.min(5, timelineZoom + 0.5))}
title="Acercar (Zoom In)"
className="text-neutral-400 hover:text-white p-0.5 transition-colors rounded hover:bg-neutral-700"
>
<ZoomIn size={12} />
</button>
</div>
<div className="flex items-center gap-2">
<select
value={timeUnit}
onChange={(e) => setTimeUnit(e.target.value as 'frames' | 'seconds')}
title="Unidad de Tiempo"
className="bg-neutral-900 text-[10px] text-neutral-400 border border-neutral-800 rounded outline-none p-1"
>
<option value="frames">Frames</option>
<option value="seconds">Segundos</option>
</select>
<div className="w-16 bg-neutral-900 text-[10px] rounded border border-neutral-800 px-1 py-1 text-center font-mono text-neutral-500" title={`${durationInFrames}f @ 30fps`}>
{timeUnit === 'frames' ? durationInFrames : (durationInFrames / 30).toFixed(2)} {timeUnit === 'frames' ? 'f' : 's'}
</div>
<div className="text-[9px] text-neutral-600 font-mono" title="Duración total">
{Math.floor(durationInFrames / 30 / 60)}:{String(Math.floor((durationInFrames / 30) % 60)).padStart(2, '0')}
</div>
</div>
</div>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More