Files
brandly/src/components/express/builder/BuilderScenePanel.tsx
T

322 lines
14 KiB
TypeScript

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>
);
};