Initial commit — Bradly branding editor platform
This commit is contained in:
+386
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
>
|
||||
Sí, eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user