Initial commit — Bradly branding editor platform

This commit is contained in:
2026-06-02 03:27:03 -05:00
commit b135a70cc7
180 changed files with 43160 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Music, Play, Pause, Clock, Loader2 } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { FileDropZone } from '../ui/FileDropZone';
import { uploadMedia } from '../../utils/mediaUploader';
import { useAudioPreview } from '../../hooks/useAudioPreview';
import { getAudioDuration, formatDuration } from '../../utils/audioMetadata';
import { AudioWaveformCanvas } from '../timeline/AudioWaveformCanvas';
interface AudioPanelProps {
onClose: () => void;
}
interface AudioItem {
src: string;
name: string;
duration: number | null;
}
/**
* Panel for adding audio files. Draggable to timeline.
* Auto-routes to audio layers. Supports preview and waveform.
*/
export const AudioPanel: React.FC<AudioPanelProps> = ({ onClose }) => {
const { designMD } = useEditor();
const [localAudios, setLocalAudios] = useState<AudioItem[]>([]);
const [brandDuration, setBrandDuration] = useState<number | null>(null);
const preview = useAudioPreview();
const [playingIdx, setPlayingIdx] = useState<number | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Load brand audio duration
useEffect(() => {
if (designMD.brandAudioUrl) {
getAudioDuration(designMD.brandAudioUrl).then(d => setBrandDuration(d));
}
}, [designMD.brandAudioUrl]);
const handleUpload = useCallback(async (files: File[]) => {
const audioFiles = files.filter(f => f.type.startsWith('audio/'));
if (audioFiles.length === 0) return;
setIsUploading(true);
try {
const items: AudioItem[] = [];
for (const file of audioFiles) {
const result = await uploadMedia(file);
let duration: number | null = null;
try {
duration = await getAudioDuration(result.url);
} catch {}
items.push({ src: result.url, name: result.originalName, duration });
}
setLocalAudios(prev => [...items, ...prev]);
} catch (err) {
console.error('Audio upload failed:', err);
} finally {
setIsUploading(false);
}
}, []);
const handleTogglePreview = useCallback((src: string, idx: number) => {
if (playingIdx === idx) {
preview.pause();
setPlayingIdx(null);
} else {
preview.setSrc(src);
preview.play();
setPlayingIdx(idx);
}
}, [playingIdx, preview]);
// Stop preview when panel closes
useEffect(() => {
return () => {
preview.pause();
};
}, []);
const allAudios = [...localAudios];
const hasBrandAudio = !!designMD.brandAudioUrl;
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Audio
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* Upload */}
<FileDropZone
accept="audio/*"
multiple
onFiles={handleUpload}
label={isUploading ? 'Subiendo...' : "Subir audio"}
sublabel={isUploading ? undefined : "MP3, WAV, OGG"}
/>
{isUploading && (
<div className="flex items-center justify-center gap-2 py-2 text-violet-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-[10px] font-medium">Subiendo al servidor...</span>
</div>
)}
{/* Brand Audio */}
{hasBrandAudio && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Audio de Marca</span>
<div
className="flex flex-col gap-2 p-2.5 bg-neutral-800/50 border border-neutral-800 rounded-lg cursor-grab active:cursor-grabbing hover:border-violet-500/40 transition-colors group"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', designMD.brandAudioUrl!);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'audio', src: designMD.brandAudioUrl }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div className="flex items-center gap-3">
<button
onClick={(e) => { e.stopPropagation(); handleTogglePreview(designMD.brandAudioUrl!, -1); }}
className="w-8 h-8 rounded-md bg-violet-600/20 border border-violet-500/30 flex items-center justify-center shrink-0 hover:bg-violet-600/40 transition-colors"
title={playingIdx === -1 ? "Pausar Preview" : "Escuchar Preview"}
>
{playingIdx === -1 ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-violet-400 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-white block truncate">Jingle de Marca</span>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-500">Arrastrar al timeline</span>
{brandDuration !== null && (
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
<Clock size={8} /> {formatDuration(brandDuration)}
</span>
)}
</div>
</div>
</div>
{/* Mini Waveform */}
<div className="bg-neutral-900/50 rounded overflow-hidden">
<AudioWaveformCanvas
src={designMD.brandAudioUrl!}
width={220}
height={24}
color="rgba(139, 92, 246, 0.4)"
resolution={100}
/>
</div>
</div>
</div>
)}
{/* Uploaded Audios */}
{localAudios.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Mis Audios</span>
<div className="space-y-2">
{localAudios.map((audio, i) => (
<div
key={`audio-${i}`}
className="flex flex-col gap-2 p-2.5 bg-neutral-950/50 border border-neutral-800/60 rounded-lg cursor-grab active:cursor-grabbing hover:border-neutral-700 transition-colors group"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', audio.src);
e.dataTransfer.setData('application/json', JSON.stringify({
type: 'audio',
src: audio.src,
fileName: audio.name,
}));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div className="flex items-center gap-3">
<button
onClick={(e) => { e.stopPropagation(); handleTogglePreview(audio.src, i); }}
className="w-8 h-8 rounded-md bg-neutral-800 flex items-center justify-center shrink-0 hover:bg-neutral-700 transition-colors"
title={playingIdx === i ? "Pausar Preview" : "Escuchar Preview"}
>
{playingIdx === i ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-neutral-400 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate" title={audio.name}>{audio.name}</span>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-600">Arrastrar al timeline</span>
{audio.duration !== null && (
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
<Clock size={8} /> {formatDuration(audio.duration)}
</span>
)}
</div>
</div>
</div>
{/* Mini Waveform */}
<div className="bg-neutral-900/30 rounded overflow-hidden">
<AudioWaveformCanvas
src={audio.src}
width={220}
height={20}
color="rgba(129, 140, 248, 0.35)"
resolution={80}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state */}
{!hasBrandAudio && localAudios.length === 0 && (
<div className="text-center py-6 text-neutral-500">
<Music size={28} className="mx-auto mb-2 opacity-40" />
<p className="text-xs font-medium">Sin audio disponible</p>
<p className="text-[10px] mt-1">Sube archivos de audio o configura el jingle de marca</p>
</div>
)}
</div>
</div>
);
};
+163
View File
@@ -0,0 +1,163 @@
import React, { useCallback } from 'react';
import { X, Hexagon } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface ShapesPanelProps {
onClose: () => void;
}
interface ShapeDef {
type: TimelineElement['shapeType'];
label: string;
svg: React.ReactNode;
}
const SHAPES: ShapeDef[] = [
{
type: 'rectangle',
label: 'Rectángulo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<rect x="4" y="8" width="40" height="32" rx="3" fill="currentColor" />
</svg>
),
},
{
type: 'circle',
label: 'Círculo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<circle cx="24" cy="24" r="20" fill="currentColor" />
</svg>
),
},
{
type: 'triangle',
label: 'Triángulo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<polygon points="24,4 44,44 4,44" fill="currentColor" />
</svg>
),
},
{
type: 'star',
label: 'Estrella',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<polygon points="24,2 29,17 46,17 33,27 38,44 24,34 10,44 15,27 2,17 19,17" fill="currentColor" />
</svg>
),
},
{
type: 'line',
label: 'Línea',
svg: (
<svg viewBox="0 0 48 12" className="w-10 h-4">
<line x1="4" y1="6" x2="44" y2="6" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
),
},
{
type: 'arrow',
label: 'Flecha',
svg: (
<svg viewBox="0 0 48 24" className="w-10 h-5">
<line x1="4" y1="12" x2="36" y2="12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<polygon points="32,4 46,12 32,20" fill="currentColor" />
</svg>
),
},
];
/**
* ShapesPanel — Grid of basic shapes to insert into the canvas.
*/
export const ShapesPanel: React.FC<ShapesPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const addShape = useCallback((shapeDef: ShapeDef) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
// Find or create a visual layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'shape',
content: shapeDef.label,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 35,
y: 35,
width: 30,
shapeType: shapeDef.type,
shapeFill: '#ffffff',
shapeStroke: 'none',
shapeStrokeWidth: 0,
shapeCornerRadius: 0,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
return (
<div className="w-72 bg-neutral-950 border-r border-neutral-800 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800 shrink-0">
<div className="flex items-center gap-2">
<Hexagon size={16} className="text-violet-400" />
<h3 className="text-sm font-bold text-white">Formas</h3>
</div>
<button
onClick={onClose}
title="Cerrar panel"
className="p-1 rounded hover:bg-neutral-800 text-neutral-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Shapes Grid */}
<div className="p-4 flex-1 overflow-y-auto custom-scrollbar">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-3">Básicas</label>
<div className="grid grid-cols-3 gap-2">
{SHAPES.map((shape) => (
<button
key={shape.type}
onClick={() => addShape(shape)}
title={`Insertar ${shape.label}`}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-xl border border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:text-violet-400 hover:border-violet-500/40 hover:bg-violet-500/5 transition-all group"
>
<div className="text-neutral-500 group-hover:text-violet-400 transition-colors">
{shape.svg}
</div>
<span className="text-[9px] font-medium">{shape.label}</span>
</button>
))}
</div>
</div>
</div>
);
};
+407
View File
@@ -0,0 +1,407 @@
import React, { useState, useCallback } from 'react';
import { Search, Volume2, Plus, Loader2 } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface SfxCategory {
name: string;
emoji: string;
effects: SoundEffect[];
}
interface SoundEffect {
name: string;
description: string;
durationSec: number;
// These would be actual URLs in production — for now they are placeholders
// that get generated/loaded on demand
generator: 'tone' | 'noise' | 'click';
frequency?: number;
}
const SFX_CATEGORIES: SfxCategory[] = [
{
name: 'Transiciones',
emoji: '🔄',
effects: [
{ name: 'Whoosh', description: 'Paso rápido', durationSec: 0.8, generator: 'noise' },
{ name: 'Swoosh Suave', description: 'Movimiento suave', durationSec: 0.6, generator: 'noise' },
{ name: 'Click', description: 'Click mecánico', durationSec: 0.2, generator: 'click' },
{ name: 'Pop', description: 'Aparición', durationSec: 0.3, generator: 'click', frequency: 800 },
],
},
{
name: 'UI / Notificaciones',
emoji: '🔔',
effects: [
{ name: 'Ding', description: 'Notificación', durationSec: 0.5, generator: 'tone', frequency: 880 },
{ name: 'Beep', description: 'Alerta simple', durationSec: 0.3, generator: 'tone', frequency: 440 },
{ name: 'Error', description: 'Error/rechazo', durationSec: 0.4, generator: 'tone', frequency: 220 },
{ name: 'Success', description: 'Éxito/aprobado', durationSec: 0.6, generator: 'tone', frequency: 660 },
],
},
{
name: 'Impacto',
emoji: '💥',
effects: [
{ name: 'Boom', description: 'Impacto bajo', durationSec: 1.0, generator: 'noise' },
{ name: 'Hit Suave', description: 'Golpe leve', durationSec: 0.4, generator: 'noise' },
{ name: 'Drum Hit', description: 'Tambor', durationSec: 0.5, generator: 'tone', frequency: 80 },
],
},
{
name: 'Ambientes',
emoji: '🌊',
effects: [
{ name: 'Lluvia', description: 'Sonido de lluvia', durationSec: 3.0, generator: 'noise' },
{ name: 'Viento', description: 'Brisa suave', durationSec: 3.0, generator: 'noise' },
{ name: 'Estática', description: 'Ruido blanco', durationSec: 2.0, generator: 'noise' },
],
},
];
/**
* Generate a simple sound effect using Web Audio API and return as blob URL.
*/
function generateSfx(effect: SoundEffect): string {
const ctx = new OfflineAudioContext(1, 44100 * effect.durationSec, 44100);
if (effect.generator === 'tone') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = effect.frequency ?? 440;
gain.gain.setValueAtTime(0.5, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(effect.durationSec);
} else if (effect.generator === 'click') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = effect.frequency ?? 1000;
gain.gain.setValueAtTime(0.8, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(effect.durationSec);
} else {
// Noise generator
const bufferSize = ctx.sampleRate * effect.durationSec;
const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = noiseBuffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.9);
source.connect(gain);
gain.connect(ctx.destination);
source.start(0);
}
// OfflineAudioContext renders synchronously in terms of API but returns a promise
// We'll create a placeholder and update async
return ''; // Will be replaced
}
/**
* Generate SFX and return a blob URL asynchronously.
*/
async function generateSfxAsync(effect: SoundEffect): Promise<string> {
const sampleRate = 44100;
const duration = effect.durationSec;
const ctx = new OfflineAudioContext(1, sampleRate * duration, sampleRate);
if (effect.generator === 'tone') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = effect.frequency ?? 440;
gain.gain.setValueAtTime(0.5, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(duration);
} else if (effect.generator === 'click') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = effect.frequency ?? 1000;
gain.gain.setValueAtTime(0.8, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(duration);
} else {
const bufferSize = sampleRate * duration;
const noiseBuffer = ctx.createBuffer(1, bufferSize, sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = noiseBuffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.9);
source.connect(gain);
gain.connect(ctx.destination);
source.start(0);
}
const rendered = await ctx.startRendering();
// Convert to WAV blob
const numChannels = 1;
const length = rendered.length * numChannels * 2 + 44;
const buffer = new ArrayBuffer(length);
const view = new DataView(buffer);
// WAV header
const writeString = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
writeString(0, 'RIFF');
view.setUint32(4, length - 8, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, rendered.length * numChannels * 2, true);
const channelData = rendered.getChannelData(0);
let offset = 44;
for (let i = 0; i < rendered.length; i++) {
const sample = Math.max(-1, Math.min(1, channelData[i]));
view.setInt16(offset, sample * 0x7FFF, true);
offset += 2;
}
const blob = new Blob([buffer], { type: 'audio/wav' });
return URL.createObjectURL(blob);
}
interface SoundEffectsPanelProps {
onClose: () => void;
}
/**
* SoundEffectsPanel — Categorized SFX library with Web Audio generated effects.
*/
export const SoundEffectsPanel: React.FC<SoundEffectsPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const [search, setSearch] = useState('');
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [insertingId, setInsertingId] = useState<string | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>(
Object.fromEntries(SFX_CATEGORIES.map(c => [c.name, true]))
);
// Preview a sound effect
const handlePreview = useCallback(async (effect: SoundEffect) => {
const id = effect.name;
setPreviewingId(id);
try {
const url = await generateSfxAsync(effect);
const audio = new Audio(url);
audio.volume = 0.5;
audio.play();
audio.onended = () => {
setPreviewingId(null);
URL.revokeObjectURL(url);
};
} catch {
setPreviewingId(null);
}
}, []);
// Insert into timeline
const handleInsert = useCallback(async (effect: SoundEffect) => {
const id = effect.name;
setInsertingId(id);
try {
const url = await generateSfxAsync(effect);
// Upload to server for persistence
const response = await fetch(url);
const blob = await response.blob();
const file = new File([blob], `sfx-${effect.name.toLowerCase().replace(/\s+/g, '-')}.wav`, { type: 'audio/wav' });
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
let persistentUrl = url;
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
persistentUrl = uploadData.url;
URL.revokeObjectURL(url);
}
// Find or create audio layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type !== 'audio') {
let audioLayer = layers.find(l => l.type === 'audio');
if (!audioLayer) {
audioLayer = { id: 'layer-audio-' + Date.now(), name: 'Audio', type: 'audio' };
setLayers(prev => [...prev, audioLayer!]);
}
targetLayerId = audioLayer.id;
setActiveLayerId(targetLayerId);
}
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newElement: TimelineElement = {
id: 'sfx-' + Date.now(),
layerId: targetLayerId,
type: 'audio',
content: persistentUrl,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + Math.round(effect.durationSec * 30)),
x: 0,
y: 0,
originalFileName: `SFX: ${effect.name}`,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newElement.id);
} catch (err) {
console.error('SFX insert error:', err);
} finally {
setInsertingId(null);
}
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
// Filter
const filteredCategories = SFX_CATEGORIES
.map(cat => ({
...cat,
effects: cat.effects.filter(e =>
!search || e.name.toLowerCase().includes(search.toLowerCase()) || e.description.toLowerCase().includes(search.toLowerCase())
),
}))
.filter(cat => cat.effects.length > 0);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Volume2 size={14} className="text-emerald-400" />
Efectos de Sonido
</h3>
<button onClick={onClose} title="Cerrar" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
</button>
</div>
{/* Search */}
<div className="p-3 border-b border-neutral-800/50">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar efectos..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-emerald-500/50"
/>
</div>
</div>
{/* Categories */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
{filteredCategories.map((cat) => (
<div key={cat.name}>
<button
onClick={() => setExpandedCategories(prev => ({ ...prev, [cat.name]: !prev[cat.name] }))}
title={`Categoría ${cat.name}`}
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/50 transition-colors text-left"
>
<span className="text-sm">{cat.emoji}</span>
<span className="text-[11px] font-semibold text-neutral-300 flex-1">{cat.name}</span>
<span className="text-[9px] text-neutral-600">{cat.effects.length}</span>
</button>
{expandedCategories[cat.name] && (
<div className="ml-1 space-y-0.5 mt-0.5">
{cat.effects.map((effect) => {
const effectId = effect.name;
const isPreviewing = previewingId === effectId;
const isInserting = insertingId === effectId;
return (
<div
key={effectId}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/40 transition-colors group"
>
{/* Preview button */}
<button
onClick={() => handlePreview(effect)}
disabled={isPreviewing}
title={`Previsualizar ${effect.name}`}
className={`p-1 rounded transition-colors ${
isPreviewing
? 'text-emerald-400 animate-pulse'
: 'text-neutral-600 hover:text-emerald-400'
}`}
>
<Volume2 size={12} />
</button>
{/* Name + Desc */}
<div className="flex-1 min-w-0">
<div className="text-[10px] font-medium text-neutral-300 truncate">{effect.name}</div>
<div className="text-[8px] text-neutral-600 truncate">{effect.description} · {effect.durationSec}s</div>
</div>
{/* Insert button */}
<button
onClick={() => handleInsert(effect)}
disabled={isInserting}
title={`Insertar ${effect.name}`}
className="p-1 rounded text-neutral-600 hover:text-emerald-400 opacity-0 group-hover:opacity-100 transition-all"
>
{isInserting ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
</button>
</div>
);
})}
</div>
)}
</div>
))}
{filteredCategories.length === 0 && (
<div className="text-center py-6 text-neutral-600 text-xs">
<Volume2 size={24} className="mx-auto mb-2 opacity-30" />
<p>No se encontraron efectos</p>
</div>
)}
</div>
</div>
);
};
+235
View File
@@ -0,0 +1,235 @@
import React, { useCallback } from 'react';
import { X, Stamp, Image as ImageIcon, Type, AtSign, Globe, Instagram } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { FileDropZone } from '../ui/FileDropZone';
import { TimelineElement } from '../../types';
interface StickersPanelProps {
onClose: () => void;
}
/**
* Panel for brand assets: branded text presets, social handles, stickers.
* Text presets use the brand font, color, and name from designMD.
*/
export const StickersPanel: React.FC<StickersPanelProps> = ({ onClose }) => {
const {
designMD, brandContent,
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const brandName = designMD.brandName || 'Mi Marca';
const font = designMD.baseFont || 'system-ui';
const color = designMD.textColor || '#ffffff';
const social = designMD.socialHandles || {};
const brandContentThumbnails = (brandContent || [])
.filter(p => p.thumbnail)
.map(p => ({ src: p.thumbnail!, name: p.name, id: p.id }));
const legacyStickers = designMD.brandStickers || [];
// Add branded text element to a visual layer
const addBrandText = useCallback((content: string, fontSize?: number, y?: number) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'text',
content,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 50,
y: y ?? 50,
fontSize,
fontFamily: font,
color: color,
useBranding: true,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId, font, color]);
// Build social text presets
const socialPresets: { label: string; content: string; icon: React.ReactNode }[] = [];
if (social.instagram) socialPresets.push({ label: 'Instagram', content: social.instagram, icon: <Instagram size={12} /> });
if (social.tiktok) socialPresets.push({ label: 'TikTok', content: social.tiktok, icon: <AtSign size={12} /> });
if (social.twitter) socialPresets.push({ label: 'Twitter/X', content: social.twitter, icon: <AtSign size={12} /> });
if (social.youtube) socialPresets.push({ label: 'YouTube', content: social.youtube, icon: <AtSign size={12} /> });
if (social.website) socialPresets.push({ label: 'Web', content: social.website, icon: <Globe size={12} /> });
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Stamp size={14} className="text-amber-400" />
Marca
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* ═══ Textos de Marca ═══ */}
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Textos de Marca</span>
<div className="space-y-1.5">
{/* Brand name — large */}
<button
onClick={() => addBrandText(brandName, 64, 40)}
title={`Añadir "${brandName}" como título`}
className="w-full flex items-center gap-2.5 px-3 py-2.5 bg-neutral-950/60 border border-amber-900/30 rounded-lg text-left hover:border-amber-500/40 hover:bg-amber-950/20 transition-all group"
>
<div className="w-8 h-8 rounded-md bg-amber-600/15 border border-amber-500/30 flex items-center justify-center shrink-0">
<Type size={14} className="text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-bold text-white block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Título grande · 64px</span>
</div>
</button>
{/* Brand name — subtitle */}
<button
onClick={() => addBrandText(brandName, 36, 50)}
title={`Añadir "${brandName}" como subtítulo`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
<Type size={12} className="text-neutral-400 group-hover:text-amber-400 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Subtítulo · 36px</span>
</div>
</button>
{/* Brand name — small watermark */}
<button
onClick={() => addBrandText(brandName, 20, 90)}
title={`Añadir "${brandName}" como marca de agua`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
<Type size={10} className="text-neutral-500 group-hover:text-amber-400 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[10px] text-neutral-400 block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Marca de agua · 20px</span>
</div>
</button>
</div>
</div>
{/* ═══ Redes Sociales ═══ */}
{socialPresets.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Redes Sociales</span>
<div className="space-y-1.5">
{socialPresets.map((sp) => (
<button
key={sp.label}
onClick={() => addBrandText(sp.content, 28, 85)}
title={`Añadir ${sp.label}: ${sp.content}`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-violet-500/30 hover:bg-violet-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-violet-600/15 border border-violet-500/30 flex items-center justify-center shrink-0 text-violet-400">
{sp.icon}
</div>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate">{sp.content}</span>
<span className="text-[9px] text-neutral-600">{sp.label} · 28px</span>
</div>
</button>
))}
</div>
</div>
)}
{/* ═══ Contenido Visual de Marca ═══ */}
{brandContentThumbnails.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Contenido Visual</span>
<div className="grid grid-cols-2 gap-2">
{brandContentThumbnails.map(item => (
<div
key={item.id}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', item.src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src: item.src, brandContentId: item.id }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<img src={item.src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt={item.name} draggable={false} />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center pointer-events-none">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
<span className="text-[8px] text-neutral-300 mt-0.5">{item.name}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Legacy Stickers */}
{legacyStickers.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Stickers</span>
<div className="grid grid-cols-2 gap-2">
{legacyStickers.map((src, i) => (
<div
key={`sticker-${i}`}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<img src={src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt="Sticker" draggable={false} />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Upload */}
<FileDropZone
accept="image/*"
multiple
onFiles={() => {}}
label="Subir assets de marca"
sublabel="PNG con transparencia"
/>
</div>
</div>
);
};
+266
View File
@@ -0,0 +1,266 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Search, Film, Image as ImageIcon, Loader2, Download, ExternalLink } from 'lucide-react';
import { searchStockPhotos, searchStockVideos, downloadStockToServer, StockPhoto, StockVideo } from '../../utils/stockMediaApi';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
type MediaType = 'photos' | 'videos';
/**
* StockMediaTab — Search and insert stock photos/videos from Pexels.
*/
export const StockMediaTab: React.FC = () => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const [query, setQuery] = useState('');
const [mediaType, setMediaType] = useState<MediaType>('photos');
const [photos, setPhotos] = useState<StockPhoto[]>([]);
const [videos, setVideos] = useState<StockVideo[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
// ─── Search ───
const doSearch = useCallback(async (q: string, p: number, type: MediaType, append = false) => {
setIsLoading(true);
try {
if (type === 'photos') {
const result = await searchStockPhotos(q || 'trending', p);
setPhotos(prev => append ? [...prev, ...result.items] : result.items);
setHasMore(result.hasMore);
} else {
const result = await searchStockVideos(q || 'trending', p);
setVideos(prev => append ? [...prev, ...result.items] : result.items);
setHasMore(result.hasMore);
}
} finally {
setIsLoading(false);
}
}, []);
// Debounced search on query change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
doSearch(query, 1, mediaType);
}, 500);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [query, mediaType, doSearch]);
// Load more (infinite scroll)
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) {
const nextPage = page + 1;
setPage(nextPage);
doSearch(query, nextPage, mediaType, true);
}
}, { threshold: 0.5 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, isLoading, page, query, mediaType, doSearch]);
// ─── Insert into canvas ───
const insertPhoto = useCallback(async (photo: StockPhoto) => {
setIsDownloading(photo.id);
try {
// Download to server for persistence
const persistentUrl = await downloadStockToServer(photo.mediumUrl, `pexels-${photo.id}.jpg`);
insertElement(persistentUrl, 'image');
} catch {
// Fallback: use direct URL
insertElement(photo.mediumUrl, 'image');
} finally {
setIsDownloading(null);
}
}, []);
const insertVideo = useCallback(async (video: StockVideo) => {
setIsDownloading(video.id);
try {
const persistentUrl = await downloadStockToServer(video.videoUrl, `pexels-${video.id}.mp4`);
insertElement(persistentUrl, 'video');
} catch {
insertElement(video.videoUrl, 'video');
} finally {
setIsDownloading(null);
}
}, []);
const insertElement = useCallback((src: string, type: 'image' | 'video') => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Visual', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type,
content: src,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + (type === 'video' ? 150 : 100)),
x: 25,
y: 25,
width: 50,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
const items = mediaType === 'photos' ? photos : videos;
return (
<div className="flex flex-col h-full">
{/* Search + Type Toggle */}
<div className="p-3 space-y-2 border-b border-neutral-800/50">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar en Pexels..."
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-violet-500/50"
/>
</div>
<div className="flex gap-1">
<button
onClick={() => setMediaType('photos')}
title="Buscar fotos"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
mediaType === 'photos'
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<ImageIcon size={12} /> Fotos
</button>
<button
onClick={() => setMediaType('videos')}
title="Buscar videos"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
mediaType === 'videos'
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<Film size={12} /> Videos
</button>
</div>
</div>
{/* Results Grid */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
{items.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-32 text-neutral-600 text-xs">
<Search size={24} className="mb-2 opacity-50" />
<span>Busca fotos o videos gratis</span>
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
{mediaType === 'photos'
? photos.map((photo) => (
<button
key={photo.id}
onClick={() => insertPhoto(photo)}
title={`${photo.alt || 'Foto'}${photo.photographer}`}
className="relative group rounded-lg overflow-hidden aspect-square bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
disabled={isDownloading === photo.id}
>
<img
src={photo.thumbUrl}
alt={photo.alt}
loading="lazy"
className="w-full h-full object-cover"
/>
{/* Overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end">
<span className="text-[8px] text-white/80 px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity truncate">
📷 {photo.photographer}
</span>
</div>
{isDownloading === photo.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 size={20} className="text-violet-400 animate-spin" />
</div>
)}
</button>
))
: videos.map((video) => (
<button
key={video.id}
onClick={() => insertVideo(video)}
title={`Video — ${video.photographer} (${video.duration}s)`}
className="relative group rounded-lg overflow-hidden aspect-video bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
disabled={isDownloading === video.id}
>
<img
src={video.thumbUrl}
alt="Video thumbnail"
loading="lazy"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Film size={24} className="text-white/70 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<span className="absolute bottom-1 right-1 text-[8px] text-white/80 bg-black/60 rounded px-1 py-0.5">
{video.duration}s
</span>
{isDownloading === video.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 size={20} className="text-violet-400 animate-spin" />
</div>
)}
</button>
))}
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-center py-4">
<Loader2 size={16} className="text-violet-400 animate-spin" />
</div>
)}
{/* Infinite scroll sentinel */}
{hasMore && <div ref={sentinelRef} className="h-4" />}
{/* Pexels attribution */}
{items.length > 0 && (
<div className="flex items-center justify-center gap-1 py-3 text-[9px] text-neutral-600">
<ExternalLink size={8} />
Fotos proporcionadas por <a href="https://pexels.com" target="_blank" rel="noopener" className="text-neutral-500 underline">Pexels</a>
</div>
)}
</div>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React, { useCallback } from 'react';
import { X, Type, Plus, AlignLeft, AlignCenter, Heading1, Heading2, Subtitles } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface TextPanelProps {
onClose: () => void;
}
const TEXT_PRESETS = [
{ label: 'Título', icon: <Heading1 size={14} />, content: 'Título', fontSize: 72, y: 30 },
{ label: 'Subtítulo', icon: <Heading2 size={14} />, content: 'Subtítulo', fontSize: 48, y: 50 },
{ label: 'Cuerpo', icon: <AlignLeft size={14} />, content: 'Texto de cuerpo', fontSize: 32, y: 60 },
{ label: 'Lower Third', icon: <Subtitles size={14} />, content: 'Lower Third', fontSize: 28, y: 85 },
{ label: 'Centrado', icon: <AlignCenter size={14} />, content: 'Texto Centrado', fontSize: 56, y: 50 },
];
/**
* Panel for adding text elements. Auto-routes to a visual layer.
*/
export const TextPanel: React.FC<TextPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const addText = useCallback((content: string, fontSize?: number, y?: number) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
// Find or create a visual layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'text',
content,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 50,
y: y ?? 50,
fontSize,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId]);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Type size={14} className="text-violet-400" />
Texto
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* Quick add */}
<button
onClick={() => addText('Nuevo Texto')}
title="Añadir texto rápido"
className="w-full flex items-center justify-center gap-2 py-2.5 bg-violet-600/20 border border-violet-500/40 text-violet-300 hover:bg-violet-600/30 hover:border-violet-400/60 rounded-lg transition-all text-sm font-medium"
>
<Plus size={14} />
Añadir Texto
</button>
{/* Presets */}
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest">Plantillas</span>
<div className="grid gap-1.5">
{TEXT_PRESETS.map((preset) => (
<button
key={preset.label}
onClick={() => addText(preset.content, preset.fontSize, preset.y)}
title={`Añadir ${preset.label}`}
className="flex items-center gap-2.5 px-3 py-2 bg-neutral-950/50 border border-neutral-800/60 rounded-lg text-neutral-400 hover:text-white hover:border-neutral-700 hover:bg-neutral-800/50 transition-all text-left group"
>
<span className="text-neutral-500 group-hover:text-violet-400 transition-colors">{preset.icon}</span>
<div className="flex flex-col">
<span className="text-[11px] font-medium leading-tight">{preset.label}</span>
<span className="text-[9px] text-neutral-600">{preset.fontSize}px</span>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
};