Initial commit — Bradly branding editor platform
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user