Initial commit — Bradly branding editor platform
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Play, Pause, RotateCcw, Film } from 'lucide-react';
|
||||
import { Player, PlayerRef } from '@remotion/player';
|
||||
import { ExpressTemplate, CompanyProfile, DesignMD } from '../../types';
|
||||
import { BrandComposition } from '../BrandComposition';
|
||||
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
|
||||
|
||||
/**
|
||||
* LivePreviewCanvas — Shared Remotion preview component.
|
||||
*
|
||||
* Compiles TemplateField[] + fieldData → TimelineElement[] → Remotion Player.
|
||||
*
|
||||
* Used in:
|
||||
* - ProductionForm (production preview)
|
||||
* - TemplateBuilder test-data mode (design-time preview)
|
||||
*/
|
||||
|
||||
export interface LivePreviewCanvasProps {
|
||||
template: ExpressTemplate;
|
||||
fieldData: Record<string, string>;
|
||||
brand: CompanyProfile;
|
||||
designMD: DesignMD;
|
||||
/** Override objectFit per field ID */
|
||||
mediaFits?: Record<string, 'cover' | 'contain' | 'fill'>;
|
||||
/** Override containBgColor per field ID */
|
||||
containBgColors?: Record<string, string | null>;
|
||||
/** Show playback controls (play/pause/reset) — default true for video */
|
||||
showControls?: boolean;
|
||||
/** Active scene ID for scene navigation */
|
||||
activeSceneId?: string | null;
|
||||
/** Callback when user navigates to a scene */
|
||||
onSceneChange?: (sceneId: string) => void;
|
||||
/** External player ref */
|
||||
playerRef?: React.RefObject<PlayerRef>;
|
||||
/** Status label (e.g. "Listo" / "Faltan campos") */
|
||||
statusLabel?: string;
|
||||
/** Whether all required fields are complete */
|
||||
isComplete?: boolean;
|
||||
}
|
||||
|
||||
/** Format frame number to mm:ss */
|
||||
function formatTime(frames: number, fps: number): string {
|
||||
const secs = Math.floor(frames / fps);
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remainSecs = secs % 60;
|
||||
return `${mins}:${String(remainSecs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Scene type colors for timeline segments */
|
||||
const SCENE_COLORS: Record<string, string> = {
|
||||
intro: '#10b981',
|
||||
content: '#8b5cf6',
|
||||
outro: '#f43f5e',
|
||||
};
|
||||
|
||||
export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
|
||||
template,
|
||||
fieldData,
|
||||
brand,
|
||||
designMD,
|
||||
mediaFits = {},
|
||||
containBgColors = {},
|
||||
showControls,
|
||||
activeSceneId,
|
||||
onSceneChange,
|
||||
playerRef: externalRef,
|
||||
statusLabel,
|
||||
isComplete = false,
|
||||
}) => {
|
||||
const internalRef = useRef<PlayerRef>(null);
|
||||
const playerRef = externalRef || internalRef;
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentFrame, setCurrentFrame] = useState(0);
|
||||
const scrubRef = useRef<HTMLDivElement>(null);
|
||||
const isScrubbing = useRef(false);
|
||||
|
||||
const fps = 30;
|
||||
const totalDuration = getTemplateDuration(template);
|
||||
const totalFrames = Math.max(30, totalDuration * fps);
|
||||
const dimensions = getAspectDimensions(template.aspectRatio);
|
||||
|
||||
// Compile template to timeline (reactive to fieldData + mediaFits)
|
||||
const compiled = useMemo(() => {
|
||||
const result = compileExpressToTimeline(template, fieldData, designMD, brand);
|
||||
// Strip transitions and apply mediaFit overrides
|
||||
result.elements = result.elements.map(el => {
|
||||
const fieldId = el.sourceFieldId;
|
||||
const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
|
||||
const bgOverride = fieldId ? containBgColors[fieldId] : undefined;
|
||||
return {
|
||||
...el,
|
||||
transitionIn: undefined,
|
||||
transitionOut: undefined,
|
||||
...(fitOverride ? { objectFit: fitOverride } : {}),
|
||||
...(bgOverride !== undefined ? { containBgColor: bgOverride } : {}),
|
||||
};
|
||||
});
|
||||
return result;
|
||||
}, [template, fieldData, designMD, brand, mediaFits, containBgColors]);
|
||||
|
||||
const playerInputProps = useMemo(() => ({
|
||||
designMD,
|
||||
timelineElements: compiled.elements,
|
||||
layers: compiled.layers,
|
||||
selectedElementId: null,
|
||||
textOverlay: '',
|
||||
brandVisibility: {
|
||||
logo: false,
|
||||
frame: false,
|
||||
background: true,
|
||||
},
|
||||
outputFormat: template.format,
|
||||
}), [designMD, compiled, template.format]);
|
||||
|
||||
// Force Player remount when media sources change (blob URLs from uploads).
|
||||
// Remotion's Player doesn't always re-render paused compositions on inputProps change.
|
||||
const playerKey = useMemo(() => {
|
||||
return compiled.elements
|
||||
.filter(el => el.type === 'video' || el.type === 'image')
|
||||
.map(el => el.content || '')
|
||||
.join('|');
|
||||
}, [compiled]);
|
||||
|
||||
const shouldShowControls = showControls ?? (template.format === 'video');
|
||||
const isMultiScene = template.scenes.length > 1;
|
||||
|
||||
// ── Frame tracking via polling ──
|
||||
useEffect(() => {
|
||||
if (!shouldShowControls) return;
|
||||
const interval = setInterval(() => {
|
||||
if (playerRef.current && !isScrubbing.current) {
|
||||
const frame = playerRef.current.getCurrentFrame();
|
||||
setCurrentFrame(frame);
|
||||
}
|
||||
}, 1000 / 15); // 15Hz polling is enough for UI update
|
||||
return () => clearInterval(interval);
|
||||
}, [playerRef, shouldShowControls]);
|
||||
|
||||
// ── Scene segments for timeline ──
|
||||
const sceneSegments = useMemo(() => {
|
||||
let offset = 0;
|
||||
return template.scenes.map(scene => {
|
||||
const durFrames = scene.durationSeconds * fps;
|
||||
const seg = {
|
||||
id: scene.id,
|
||||
name: scene.type === 'intro' ? 'INTRO' : scene.type === 'outro' ? 'OUTRO' : scene.name,
|
||||
type: scene.type || 'content',
|
||||
startFrame: offset,
|
||||
endFrame: offset + durFrames,
|
||||
widthPct: (durFrames / totalFrames) * 100,
|
||||
};
|
||||
offset += durFrames;
|
||||
return seg;
|
||||
});
|
||||
}, [template, fps, totalFrames]);
|
||||
|
||||
const handlePlayToggle = useCallback(() => {
|
||||
if (playerRef.current) {
|
||||
if (isPlaying) {
|
||||
playerRef.current.pause();
|
||||
} else {
|
||||
playerRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
}, [isPlaying, playerRef]);
|
||||
|
||||
const handleSelectScene = useCallback((sceneId: string) => {
|
||||
if (!playerRef.current) return;
|
||||
let frameOffset = 0;
|
||||
for (const scene of template.scenes) {
|
||||
if (scene.id === sceneId) break;
|
||||
frameOffset += scene.durationSeconds * fps;
|
||||
}
|
||||
playerRef.current.seekTo(frameOffset);
|
||||
playerRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
onSceneChange?.(sceneId);
|
||||
}, [template, fps, playerRef, onSceneChange]);
|
||||
|
||||
// ── Scrub bar interactions ──
|
||||
const scrubToPosition = useCallback((clientX: number) => {
|
||||
if (!scrubRef.current || !playerRef.current) return;
|
||||
const rect = scrubRef.current.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
const frame = Math.round(pct * (totalFrames - 1));
|
||||
playerRef.current.seekTo(frame);
|
||||
setCurrentFrame(frame);
|
||||
}, [playerRef, totalFrames]);
|
||||
|
||||
const handleScrubDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
isScrubbing.current = true;
|
||||
playerRef.current?.pause();
|
||||
setIsPlaying(false);
|
||||
scrubToPosition(e.clientX);
|
||||
}, [playerRef, scrubToPosition]);
|
||||
|
||||
const handleScrubMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!isScrubbing.current) return;
|
||||
scrubToPosition(e.clientX);
|
||||
}, [scrubToPosition]);
|
||||
|
||||
const handleScrubUp = useCallback(() => {
|
||||
isScrubbing.current = false;
|
||||
}, []);
|
||||
|
||||
const playheadPct = totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0;
|
||||
|
||||
// Determine which scene the playhead is currently in
|
||||
const activeSceneFromPlayhead = useMemo(() => {
|
||||
for (const seg of sceneSegments) {
|
||||
if (currentFrame >= seg.startFrame && currentFrame < seg.endFrame) return seg.id;
|
||||
}
|
||||
return sceneSegments[sceneSegments.length - 1]?.id;
|
||||
}, [currentFrame, sceneSegments]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center relative z-10 overflow-hidden">
|
||||
{/* Status header */}
|
||||
{statusLabel !== undefined && (
|
||||
<div className="absolute top-4 left-5 flex items-center gap-2 z-20">
|
||||
<div className={`w-2 h-2 rounded-full ${isComplete ? 'bg-emerald-400' : 'bg-amber-400 animate-pulse'}`} />
|
||||
<span className="text-xs font-semibold text-neutral-300">Preview en vivo</span>
|
||||
<span className={`text-[9px] px-2 py-0.5 rounded-full font-medium ${
|
||||
isComplete
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'bg-amber-500/10 text-amber-400'
|
||||
}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Player container */}
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/60 border border-neutral-800/40"
|
||||
style={{
|
||||
width: template.aspectRatio === '9:16' ? 240
|
||||
: template.aspectRatio === '1:1' ? 320
|
||||
: template.aspectRatio === '4:5' ? 280
|
||||
: 420,
|
||||
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
|
||||
maxHeight: 'calc(100% - 160px)',
|
||||
}}
|
||||
>
|
||||
<Player
|
||||
key={playerKey}
|
||||
ref={playerRef}
|
||||
component={BrandComposition}
|
||||
inputProps={playerInputProps}
|
||||
durationInFrames={totalFrames}
|
||||
compositionWidth={dimensions.w}
|
||||
compositionHeight={dimensions.h}
|
||||
fps={fps}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
controls={false}
|
||||
autoPlay={false}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══ Timeline Controls ═══ */}
|
||||
{shouldShowControls && (
|
||||
<div className="mt-4 w-full max-w-md px-4 z-10 space-y-2">
|
||||
{/* ── Scrub Bar with scene segments ── */}
|
||||
<div
|
||||
ref={scrubRef}
|
||||
className="relative h-7 cursor-col-resize group rounded-lg overflow-hidden"
|
||||
onPointerDown={handleScrubDown}
|
||||
onPointerMove={handleScrubMove}
|
||||
onPointerUp={handleScrubUp}
|
||||
onPointerCancel={handleScrubUp}
|
||||
title="Arrastra para navegar"
|
||||
>
|
||||
{/* Scene segment blocks */}
|
||||
<div className="absolute inset-0 flex gap-px rounded-lg overflow-hidden">
|
||||
{sceneSegments.map(seg => {
|
||||
const isActive = activeSceneFromPlayhead === seg.id;
|
||||
const color = SCENE_COLORS[seg.type] || SCENE_COLORS.content;
|
||||
return (
|
||||
<div
|
||||
key={seg.id}
|
||||
className="relative h-full flex items-center justify-center transition-all"
|
||||
style={{
|
||||
width: `${seg.widthPct}%`,
|
||||
backgroundColor: isActive ? `${color}25` : `${color}10`,
|
||||
borderBottom: `2px solid ${isActive ? color : `${color}40`}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[7px] font-bold tracking-wider truncate px-1 pointer-events-none select-none"
|
||||
style={{ color: isActive ? color : `${color}80` }}
|
||||
>
|
||||
{seg.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress fill */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full pointer-events-none transition-[width] duration-75"
|
||||
style={{
|
||||
width: `${playheadPct}%`,
|
||||
background: 'linear-gradient(90deg, rgba(139,92,246,0.1), rgba(139,92,246,0.05))',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Playhead line */}
|
||||
<div
|
||||
className="absolute top-0 h-full w-0.5 pointer-events-none transition-[left] duration-75 z-10"
|
||||
style={{
|
||||
left: `${playheadPct}%`,
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
boxShadow: '0 0 4px rgba(139,92,246,0.6)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Playhead thumb (appears on hover / scrub) */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full bg-white shadow-lg shadow-violet-500/30 border-2 border-violet-500 pointer-events-none z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ left: `${playheadPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Controls row ── */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePlayToggle}
|
||||
title={isPlaying ? 'Pausar' : 'Reproducir'}
|
||||
className="w-8 h-8 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-md"
|
||||
>
|
||||
{isPlaying ? <Pause size={12} fill="currentColor" /> : <Play size={12} fill="currentColor" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { playerRef.current?.seekTo(0); setCurrentFrame(0); setIsPlaying(false); playerRef.current?.pause(); }}
|
||||
title="Reiniciar"
|
||||
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<RotateCcw size={10} />
|
||||
</button>
|
||||
|
||||
{/* Time display */}
|
||||
<span className="text-[10px] font-mono text-neutral-400 ml-1">
|
||||
{formatTime(currentFrame, fps)}
|
||||
<span className="text-neutral-600 mx-0.5">/</span>
|
||||
{formatTime(totalFrames, fps)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Scene navigation buttons */}
|
||||
{isMultiScene && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{template.scenes.map(scene => {
|
||||
const color = SCENE_COLORS[scene.type || 'content'] || SCENE_COLORS.content;
|
||||
const isActive = activeSceneFromPlayhead === scene.id;
|
||||
const label = scene.type === 'intro' ? 'IN'
|
||||
: scene.type === 'outro' ? 'OUT'
|
||||
: scene.name.slice(0, 4);
|
||||
return (
|
||||
<button
|
||||
key={scene.id}
|
||||
onClick={() => handleSelectScene(scene.id)}
|
||||
title={`${scene.type === 'intro' ? 'Intro' : scene.type === 'outro' ? 'Outro' : scene.name} — ${scene.durationSeconds}s`}
|
||||
className="px-2 py-0.5 rounded text-[7px] font-bold border transition-all uppercase tracking-wider"
|
||||
style={{
|
||||
borderColor: isActive ? `${color}80` : 'rgba(64,64,64,0.5)',
|
||||
backgroundColor: isActive ? `${color}15` : 'transparent',
|
||||
color: isActive ? color : 'rgb(115,115,115)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint */}
|
||||
<p className="absolute bottom-4 text-[10px] text-neutral-600 z-10">
|
||||
Se actualiza al llenar los campos
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Type, Image as ImageIcon, Video, Upload, AlertCircle,
|
||||
Maximize2, Minimize2, Move, Pipette, X,
|
||||
} from 'lucide-react';
|
||||
import { TemplateField, DesignMD } from '../../types';
|
||||
|
||||
/**
|
||||
* TemplateFieldInput — Shared form field component for TemplateField.
|
||||
*
|
||||
* Used in:
|
||||
* - ProductionForm (live mode — user fills real data)
|
||||
* - FormPreviewPanel (disabled mode — shows form mockup in builder)
|
||||
* - TemplateBuilder test-data mode (live — designer fills test data)
|
||||
*/
|
||||
|
||||
export interface TemplateFieldInputProps {
|
||||
field: TemplateField;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
designMD: DesignMD;
|
||||
/** Media fit mode for image/video fields */
|
||||
mediaFit?: 'cover' | 'contain' | 'fill';
|
||||
/** Callback when user changes the media fit mode */
|
||||
onMediaFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
|
||||
/** Background color for contain mode empty space (null = transparent) */
|
||||
containBgColor?: string | null;
|
||||
/** Callback when user changes the contain background color */
|
||||
onContainBgColorChange?: (color: string | null) => void;
|
||||
/** When true, all inputs are disabled (form preview mode) */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TemplateFieldInput: React.FC<TemplateFieldInputProps> = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
designMD,
|
||||
mediaFit,
|
||||
onMediaFitChange,
|
||||
containBgColor,
|
||||
onContainBgColorChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const isText = field.type === 'text';
|
||||
const isMedia = field.type === 'image' || field.type === 'video';
|
||||
const isVideoField = field.type === 'video';
|
||||
const isMultiline = field.rules?.multiline;
|
||||
const maxChars = field.rules?.maxChars;
|
||||
|
||||
const resolvedFont = (() => {
|
||||
if (field.style?.textRole === 'title') return designMD.titleFont || designMD.baseFont;
|
||||
if (field.style?.textRole === 'subtitle') return designMD.subtitleFont || designMD.baseFont;
|
||||
return designMD.paragraphFont || designMD.baseFont;
|
||||
})();
|
||||
|
||||
const currentFit = mediaFit || 'cover';
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Label */}
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300 font-medium">
|
||||
{isText && <Type size={11} className="text-sky-400" />}
|
||||
{isMedia && (isVideoField
|
||||
? <Video size={11} className="text-sky-400" />
|
||||
: <ImageIcon size={11} className="text-sky-400" />
|
||||
)}
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-400 text-[10px]">*</span>}
|
||||
</label>
|
||||
|
||||
{/* Text input */}
|
||||
{isText && (
|
||||
isMultiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.content || `Escribe ${field.label.toLowerCase()}...`}
|
||||
rows={3}
|
||||
maxLength={maxChars || undefined}
|
||||
disabled={disabled}
|
||||
className={`w-full bg-neutral-800/50 border rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 resize-none focus:outline-none focus:border-violet-500/50 transition-colors ${
|
||||
disabled ? 'text-neutral-500 cursor-not-allowed' : ''
|
||||
} ${
|
||||
error ? 'border-red-500/50' : 'border-neutral-700'
|
||||
}`}
|
||||
style={{ fontFamily: resolvedFont }}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.content || `Escribe ${field.label.toLowerCase()}...`}
|
||||
maxLength={maxChars || undefined}
|
||||
disabled={disabled}
|
||||
className={`w-full bg-neutral-800/50 border rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-colors ${
|
||||
disabled ? 'text-neutral-500 cursor-not-allowed' : ''
|
||||
} ${
|
||||
error ? 'border-red-500/50' : 'border-neutral-700'
|
||||
}`}
|
||||
style={{
|
||||
fontFamily: resolvedFont,
|
||||
fontWeight: field.style.fontWeight || 400,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Media upload */}
|
||||
{isMedia && (
|
||||
<label
|
||||
className={`flex flex-col items-center justify-center h-24 border-2 border-dashed rounded-lg transition-colors ${
|
||||
disabled ? 'cursor-default' : 'cursor-pointer'
|
||||
} ${
|
||||
value
|
||||
? 'border-violet-500/30 bg-violet-950/10'
|
||||
: error
|
||||
? 'border-red-500/30 bg-red-950/5'
|
||||
: 'border-neutral-700 bg-neutral-800/30 hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
{value ? (
|
||||
<div className="relative w-full h-full">
|
||||
{isVideoField ? (
|
||||
<video
|
||||
src={value}
|
||||
muted
|
||||
autoPlay
|
||||
loop
|
||||
playsInline
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={value}
|
||||
alt={field.label}
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onChange(''); }}
|
||||
title="Quitar media"
|
||||
className="absolute top-1 right-1 bg-black/70 text-white text-[8px] px-1.5 py-0.5 rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={18} className="text-neutral-600 mb-1.5" />
|
||||
<span className="text-[9px] text-neutral-600">
|
||||
{isVideoField ? 'Subir video' : 'Subir imagen'}
|
||||
</span>
|
||||
{field.rules?.aspectRatio && (
|
||||
<span className="text-[8px] text-neutral-700 mt-0.5">
|
||||
Ratio: {field.rules.aspectRatio}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disabled && (
|
||||
<input
|
||||
type="file"
|
||||
accept={isVideoField ? 'video/*' : 'image/*'}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
onChange(url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Media Fit Selector — shown when media is uploaded and not disabled */}
|
||||
{isMedia && value && !disabled && onMediaFitChange && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[8px] text-neutral-500 mr-1">Ajuste:</span>
|
||||
{([
|
||||
{ key: 'cover' as const, label: 'Cover', icon: <Maximize2 size={9} />, tip: 'Llena el área, recorta bordes' },
|
||||
{ key: 'contain' as const, label: 'Contain', icon: <Minimize2 size={9} />, tip: 'Muestra completo, puede tener vacíos' },
|
||||
{ key: 'fill' as const, label: 'Fill', icon: <Move size={9} />, tip: 'Estira para llenar (puede distorsionar)' },
|
||||
]).map(opt => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
title={opt.tip}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onMediaFitChange(opt.key); }}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-medium border transition-all ${
|
||||
currentFit === opt.key
|
||||
? 'border-violet-500/50 bg-violet-500/15 text-violet-300'
|
||||
: 'border-neutral-800 bg-neutral-900 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contain Background Color Picker — shown when fit=contain, media uploaded, not disabled */}
|
||||
{isMedia && value && !disabled && currentFit === 'contain' && onContainBgColorChange && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[8px] text-neutral-500">Fondo:</span>
|
||||
{/* Color swatch — click opens native picker */}
|
||||
<button
|
||||
type="button"
|
||||
title={containBgColor ? `Color: ${containBgColor}` : 'Seleccionar color de fondo'}
|
||||
onClick={(e) => { e.preventDefault(); colorInputRef.current?.click(); }}
|
||||
className="w-5 h-5 rounded border border-neutral-700 hover:border-neutral-500 transition-colors overflow-hidden flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: containBgColor || undefined,
|
||||
// Checkerboard pattern for transparent
|
||||
...(!containBgColor ? {
|
||||
backgroundImage: 'linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%)',
|
||||
backgroundSize: '6px 6px',
|
||||
backgroundPosition: '0 0, 0 3px, 3px -3px, -3px 0px',
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
{!containBgColor && <Pipette size={8} className="text-neutral-400" />}
|
||||
</button>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={containBgColor || '#000000'}
|
||||
onChange={(e) => onContainBgColorChange(e.target.value)}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
{/* Transparent toggle */}
|
||||
<button
|
||||
type="button"
|
||||
title="Fondo transparente"
|
||||
onClick={(e) => { e.preventDefault(); onContainBgColorChange(null); }}
|
||||
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[8px] font-medium border transition-all ${
|
||||
!containBgColor
|
||||
? 'border-violet-500/50 bg-violet-500/15 text-violet-300'
|
||||
: 'border-neutral-800 bg-neutral-900 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<X size={7} />
|
||||
Transparente
|
||||
</button>
|
||||
{/* Quick color — show clear button when color is set */}
|
||||
{containBgColor && (
|
||||
<span className="text-[7px] text-neutral-600 font-mono">{containBgColor}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error / validation hints */}
|
||||
{error && (
|
||||
<p className="text-[9px] text-red-400 flex items-center gap-1">
|
||||
<AlertCircle size={9} /> {error}
|
||||
</p>
|
||||
)}
|
||||
{!error && maxChars && isText && (
|
||||
<p className="text-[8px] text-neutral-600 flex items-center gap-1">
|
||||
<AlertCircle size={8} /> Máximo {maxChars} caracteres
|
||||
{value && <span className="ml-auto">{value.length}/{maxChars}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user