feat: AI Brand Voice Translator integration and Mesh Content fix

This commit is contained in:
2026-06-03 23:56:58 -05:00
parent ad8622e243
commit 0676e9f0a8
40 changed files with 3186 additions and 1481 deletions
+64 -15
View File
@@ -1,13 +1,16 @@
import React, { useState, useCallback } from 'react';
import { Save, AlertCircle, Crown, FolderOpen, Sparkles } from 'lucide-react';
import { DesignMD, CompanyProfile } from '../types';
import React, { useState, useCallback, useEffect } from 'react';
import { Save, AlertCircle, Crown, FolderOpen, Sparkles, Image as ImageIcon, Film, Volume2, Music } from 'lucide-react';
import { CustomVideoPlayer } from './ui/CustomVideoPlayer';
import { useMediaResolver } from '../hooks/useMediaResolver';
import { DesignMD, CompanyProfile, BrandAsset } from '../types';
import { BrandTabGeneral } from './brand/BrandTabGeneral';
import { BrandTabVisual } from './brand/BrandTabVisual';
import { BrandTabTypography } from './brand/BrandTabTypography';
import { BrandTabMedia } from './brand/BrandTabMedia';
import { BrandTabGenerated } from './brand/BrandTabGenerated';
import { BrandTabVoice } from './brand/BrandTabVoice';
import { BrandPreview } from './brand/BrandPreview';
import { Toast } from './ui/Toast';
import { UnifiedMediaItem } from './content-grid/UnifiedMediaLibrary';
interface BrandArchitectureProps {
company: CompanyProfile;
@@ -22,8 +25,8 @@ const TABS = [
{ id: 'general', label: 'Información', icon: '📋' },
{ id: 'visual', label: 'Visual y Colores', icon: '🎨' },
{ id: 'typography', label: 'Tipografía', icon: '🔤' },
{ id: 'media', label: 'Video y Audio', icon: '🎬' },
{ id: 'generated', label: 'Generados', icon: '' },
{ id: 'voice', label: 'Voz de Marca', icon: '🎙️' },
{ id: 'media', label: 'Librería', icon: '📁' },
] as const;
type TabId = typeof TABS[number]['id'];
@@ -34,7 +37,9 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
const [activeTab, setActiveTab] = useState<TabId>('general');
const [showToast, setShowToast] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [selectedMediaItem, setSelectedMediaItem] = useState<BrandAsset | null>(null);
const { getMediaUrl } = useMediaResolver();
const validate = useCallback((): string[] => {
const errors: string[] = [];
if (!company?.name || company.name.trim().length < 2) {
@@ -63,8 +68,8 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
const handleOpenFolder = async () => {
if (window.electronAPI && company?.id) {
const workspacePath = await window.electronAPI.fs.getWorkspacePath();
const folderPath = `${workspacePath}/${company.id}`;
const wp = await window.electronAPI.fs.getWorkspacePath();
const folderPath = `${wp}/${company.id}`;
await window.electronAPI.fs.openFolder(folderPath);
}
};
@@ -210,26 +215,70 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
{activeTab === 'typography' && (
<BrandTabTypography designMD={designMD} handleDesignChange={handleDesignChange} />
)}
{activeTab === 'voice' && (
<BrandTabVoice company={company} handleCompanyChange={handleCompanyChange} />
)}
{activeTab === 'media' && (
<BrandTabMedia
company={company}
designMD={designMD}
handleDesignChange={handleDesignChange}
onEditAsset={onEditAsset}
onSelectAsset={setSelectedMediaItem}
selectedAssetId={selectedMediaItem?.id}
/>
)}
{activeTab === 'generated' && (
<BrandTabGenerated company={company} />
)}
</div>
</div>
{/* Preview Column */}
{activeTab === 'generated' ? (
<div className="flex-1 bg-neutral-950 flex flex-col items-center justify-center text-neutral-500">
<Sparkles className="w-12 h-12 mb-4 opacity-50" />
<p>Selecciona un archivo generado para previsualizarlo.</p>
{activeTab === 'media' ? (
<div className="flex-1 bg-neutral-950 flex flex-col items-center justify-center p-8">
{selectedMediaItem ? (
<div className="w-full h-full flex flex-col items-center justify-center">
<div className="w-full max-h-[80%] flex items-center justify-center bg-black/40 rounded-xl overflow-hidden border border-neutral-800 shadow-2xl">
{selectedMediaItem.type === 'video' ? (
<CustomVideoPlayer
src={getMediaUrl(selectedMediaItem.url || selectedMediaItem.path || '')}
autoPlay
className="w-full h-full"
/>
) : selectedMediaItem.type === 'audio' ? (
<div className="flex flex-col items-center gap-6 p-12">
<div className="w-24 h-24 rounded-full bg-rose-500/10 flex items-center justify-center">
<Music className="w-12 h-12 text-rose-500" />
</div>
<audio
src={getMediaUrl(selectedMediaItem.url || selectedMediaItem.path || '')}
controls
autoPlay
className="w-64"
/>
</div>
) : (
<img
src={getMediaUrl(selectedMediaItem.url || selectedMediaItem.path || '')}
alt={selectedMediaItem.id}
className="max-w-full max-h-full object-contain"
/>
)}
</div>
<div className="mt-6 text-center space-y-1">
<h3 className="text-lg font-medium text-white font-mono">
{selectedMediaItem.id}
</h3>
<p className="text-sm text-neutral-400">
{'date' in selectedMediaItem && selectedMediaItem.date ? new Date((selectedMediaItem as any).date).toLocaleString() : 'Asset Base'}
</p>
</div>
</div>
) : (
<div className="flex flex-col items-center text-neutral-500">
<Sparkles className="w-12 h-12 mb-4 opacity-50" />
<p>Selecciona un archivo para previsualizarlo.</p>
</div>
)}
</div>
) : (
<BrandPreview
+13
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { LayoutTemplate, Menu, Home, Settings, Download, ZoomIn, ZoomOut, X, CalendarDays, Sparkles, Play, FolderOpen } from 'lucide-react';
import { AISettingsPanel } from './settings/AISettingsPanel';
interface TopHeaderProps {
currentStep: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
@@ -37,6 +38,7 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
titleOverride,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
const [showAISettings, setShowAISettings] = useState(false);
const isStudio = currentStep === 'studio';
return (
@@ -86,11 +88,22 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
>
<FolderOpen size={14} /> Abrir
</button>
<div className="h-px bg-neutral-700 my-1" />
<button
onClick={() => { setShowAISettings(true); setMenuOpen(false); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<Sparkles size={14} className="text-violet-400" /> Configuración IA
</button>
</div>
</>
)}
</div>
{showAISettings && (
<AISettingsPanel onClose={() => setShowAISettings(false)} />
)}
<div className="flex items-center gap-2">
<div className="bg-violet-600/20 p-1 rounded text-violet-400">
<LayoutTemplate size={14} />
+156
View File
@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { Sparkles, RefreshCcw, Undo2, Check, AlertCircle } from 'lucide-react';
import { Platform } from '../../types';
interface CopyAssistantProps {
brandId: string;
platforms: Platform[];
description: string;
previousCaption?: string;
onApplyCopy: (copy: string) => void;
onApplyHashtags: (hashtags: string[]) => void;
onUndo: () => void;
}
export const CopyAssistant: React.FC<CopyAssistantProps> = ({
brandId,
platforms,
description,
previousCaption,
onApplyCopy,
onApplyHashtags,
onUndo
}) => {
const [isGenerating, setIsGenerating] = useState(false);
const [result, setResult] = useState<{ copy: string; hashtags: string[] } | null>(null);
const [error, setError] = useState<string | null>(null);
const handleGenerate = async (isRetry = false) => {
if (!description.trim()) {
setError('Escribe una descripción primero para tener contexto.');
return;
}
setIsGenerating(true);
setError(null);
try {
if (window.electronAPI?.ai) {
// En caso de regenerar, podríamos añadir una pequeña instrucción extra, o simplemente
// volver a lanzar el prompt (con temperatura > 0 ya generará algo distinto).
const promptToUse = isRetry
? `${description}\n\n[INSTRUCCIÓN ADICIONAL]: Genera una variación diferente a la anterior, tal vez con otro enfoque o gancho.`
: description;
const res = await window.electronAPI.ai.generateCopy({
brandId,
userPrompt: promptToUse,
platforms,
purpose: 'caption'
});
if (res.success && res.data) {
setResult({
copy: res.data.copy || '',
hashtags: res.data.hashtags || []
});
} else {
setError(res.error || 'Error al generar.');
}
}
} catch (err: any) {
setError(err.message || 'Error de conexión.');
} finally {
setIsGenerating(false);
}
};
return (
<div className="bg-neutral-900/50 border border-violet-900/30 rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-violet-900/10 border-b border-violet-900/20">
<div className="flex items-center gap-1.5 text-violet-400 font-medium text-xs">
<Sparkles size={14} /> Asistente de Copy IA
</div>
<div className="flex items-center gap-2">
{previousCaption && (
<button
onClick={onUndo}
className="flex items-center gap-1 text-[10px] text-neutral-400 hover:text-white transition-colors"
title="Restaurar caption original"
>
<Undo2 size={12} /> Deshacer
</button>
)}
<button
onClick={() => handleGenerate(!!result)}
disabled={isGenerating}
className="flex items-center gap-1 px-2.5 py-1 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-[10px] font-semibold rounded-md transition-colors"
>
{isGenerating ? (
<>
<div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Generando...
</>
) : result ? (
<>
<RefreshCcw size={12} /> Regenerar
</>
) : (
<>
<Sparkles size={12} /> Generar
</>
)}
</button>
</div>
</div>
{error && (
<div className="px-4 py-3 text-xs text-rose-400 flex items-start gap-2 bg-rose-950/20">
<AlertCircle size={14} className="shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
{result && !isGenerating && (
<div className="p-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-neutral-500 uppercase">Sugerencia de Copy</span>
<button
onClick={() => onApplyCopy(result.copy)}
className="flex items-center gap-1 text-[10px] text-violet-400 hover:text-violet-300 font-medium"
>
<Check size={12} /> Usar este Copy
</button>
</div>
<div className="text-xs text-neutral-300 bg-neutral-950 p-3 rounded-lg whitespace-pre-wrap font-mono">
{result.copy}
</div>
</div>
{result.hashtags.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-neutral-500 uppercase">Hashtags</span>
<button
onClick={() => onApplyHashtags(result.hashtags)}
className="flex items-center gap-1 text-[10px] text-violet-400 hover:text-violet-300 font-medium"
>
<Check size={12} /> Añadir Hashtags
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{result.hashtags.map((tag, i) => (
<span key={i} className="text-xs text-fuchsia-300 bg-fuchsia-900/20 px-2 py-0.5 rounded border border-fuchsia-900/30 font-mono">
#{tag}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
@@ -1,23 +0,0 @@
import React from 'react';
import { CompanyProfile } from '../../types';
import { GeneratedMediaList } from '../content-grid/GeneratedMediaList';
interface BrandTabGeneratedProps {
company: CompanyProfile;
}
export const BrandTabGenerated: React.FC<BrandTabGeneratedProps> = ({ company }) => {
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-white mb-2 flex items-center gap-2">
Contenido Generado
</h3>
<p className="text-sm text-neutral-400">
Archivos renderizados y guardados para la marca {company.name}.
</p>
</div>
<GeneratedMediaList brandId={company.id} draggable={false} />
</div>
);
};
+169 -372
View File
@@ -1,388 +1,185 @@
import React, { useCallback } from 'react';
import { Film, Volume2, Music, X, Upload, Wand2, Maximize2, Minimize2, Move, Pipette } from 'lucide-react';
import { DesignMD, CompanyProfile } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
import React, { useState } from 'react';
import { Film } from 'lucide-react';
import { DesignMD, CompanyProfile, BrandAsset } from '../../types';
import { UnifiedMediaLibrary, UnifiedMediaItem } from '../content-grid/UnifiedMediaLibrary';
type AssetFilter = 'all' | 'image' | 'video' | 'audio';
type SourceFilter = 'all' | 'uploaded' | 'generated';
interface BrandTabMediaProps {
company: CompanyProfile;
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
handleDesignChange: (key: keyof DesignMD, value: any) => void;
onEditAsset?: (type: keyof DesignMD, url: string) => void;
onSelectAsset?: (asset: BrandAsset) => void;
selectedAssetId?: string;
}
/**
* BrandTabMedia — Upload-only panel for brand video/audio assets.
*
* Only handles uploading the intro video, outro video, and brand audio.
* All positioning, fit, duration, and blend controls live in the TemplateBuilder
* (per-template segment configuration), avoiding collisions.
*/
export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type: keyof DesignMD, url: string) => void }> = ({ company, designMD, handleDesignChange, onEditAsset }) => {
export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ company, designMD, handleDesignChange, onSelectAsset, selectedAssetId }) => {
const assets = designMD.brandAssets || [];
const [uploadingBrandAsset, setUploadingBrandAsset] = useState(false);
const [uploadingGenerated, setUploadingGenerated] = useState(false);
const [filterType, setFilterType] = useState<AssetFilter>('all');
const [filterSource, setFilterSource] = useState<SourceFilter>('all');
const [refreshTrigger, setRefreshTrigger] = useState(0);
/** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
if (!url) return;
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (video.duration && isFinite(video.duration)) {
const frames = Math.round(video.duration * 30); // 30fps
handleDesignChange(key, Math.max(15, Math.min(300, frames)));
const handleBrandAssetFiles = async (files: File[]) => {
if (files.length === 0) return;
setUploadingBrandAsset(true);
try {
let currentWorkspacePath = '';
if (window.electronAPI) {
currentWorkspacePath = await window.electronAPI.fs.getWorkspacePath();
}
video.remove();
};
video.onerror = () => video.remove();
video.src = url;
}, [handleDesignChange]);
const newAssets = [...assets];
for (const file of files) {
const baseName = file.name.split('.')[0].replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
let uniqueId = baseName;
let counter = 1;
while (newAssets.some(a => a.id === uniqueId)) {
uniqueId = `${baseName}-${counter}`;
counter++;
}
const formData = new FormData();
formData.append('file', file);
let type: 'image' | 'video' | 'audio' = 'image';
let subfolder = 'files/images';
if (file.type.startsWith('video/')) {
type = 'video';
subfolder = 'files/videos';
} else if (file.type.startsWith('audio/')) {
type = 'audio';
subfolder = 'files/audios';
}
if (currentWorkspacePath && company.id) {
formData.append('brandId', company.id);
formData.append('workspacePath', currentWorkspacePath);
formData.append('subfolder', subfolder);
}
const endpoint = (currentWorkspacePath && company.id) ? '/api/upload/brand' : '/api/upload';
const res = await fetch(endpoint, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
if (data.url) {
newAssets.push({
id: uniqueId,
type,
url: data.url,
path: data.path
});
}
}
handleDesignChange('brandAssets', newAssets);
} catch (err) {
console.error('Asset upload failed:', err);
alert('Ocurrió un error al subir los archivos.');
} finally {
setUploadingBrandAsset(false);
}
};
const handleDeleteAsset = (id: string) => {
if (confirm(`¿Estás seguro de que deseas eliminar el asset "${id}"?`)) {
handleDesignChange('brandAssets', assets.filter(a => a.id !== id));
}
};
const handleRenameAsset = (oldId: string, newId: string) => {
if (newId !== oldId && assets.some(a => a.id === newId)) {
alert('Este identificador ya existe.');
return;
}
const newAssets = assets.map(a => {
if (a.id === oldId) return { ...a, id: newId };
return a;
});
handleDesignChange('brandAssets', newAssets);
};
const onSelectUnified = (item: UnifiedMediaItem) => {
if (!onSelectAsset) return;
// Map UnifiedMediaItem back to BrandAsset if it's uploaded
if (item.source === 'uploaded') {
const asset = assets.find(a => a.id === item.id);
if (asset) onSelectAsset(asset);
} else {
// It's generated. We can construct a mock BrandAsset to show in preview
onSelectAsset({
id: item.name || item.id,
type: item.type,
path: item.path,
url: item.url,
date: item.date
});
}
};
return (
<div className="space-y-5">
{/* Section title */}
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Film size={16} className="text-violet-400" />
Archivos de Video y Audio
</h3>
<p className="text-xs text-neutral-500 leading-relaxed">
Sube los videos y audio de tu marca. La posición, duración y estilo se configuran en cada plantilla.
</p>
<div className="flex flex-col h-full space-y-6">
{/* Encabezado */}
<div className="flex items-center justify-between shrink-0">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Film size={16} className="text-violet-400" />
Librería Unificada
</h3>
<p className="text-xs text-neutral-500 leading-relaxed max-w-xl">
Gestiona tus archivos base de marca y tus renders generados en un solo lugar.
</p>
</div>
<div className="flex gap-2">
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as SourceFilter)}
className="bg-neutral-900 border border-neutral-800 text-neutral-300 text-xs rounded-md px-2 py-1 outline-none"
>
<option value="all">Todos los orígenes</option>
<option value="uploaded">Assets Base (Subidos)</option>
<option value="generated">Renders Generados</option>
</select>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as AssetFilter)}
className="bg-neutral-900 border border-neutral-800 text-neutral-300 text-xs rounded-md px-2 py-1 outline-none"
>
<option value="all">Todos los tipos</option>
<option value="image">Imágenes</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
</div>
</div>
{/* ═══ Intro Video ═══ */}
<VideoUploadSimple
company={company}
label="Video de Cabezote (Intro)"
description="Se usará automáticamente en plantillas que incluyan segmento de intro de marca"
videoUrl={designMD.introVideoUrl || ''}
accentColor="#10b981"
onUrlChange={(url) => {
handleDesignChange('introVideoUrl', url);
if (url) probeVideoDuration(url, 'introDurationFrames');
}}
onClear={() => {
handleDesignChange('introVideoUrl', '');
handleDesignChange('introDurationFrames', 60);
}}
onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')}
showEdit={!!(designMD.introVideoUrl && onEditAsset)}
fit={designMD.introVideoFit}
onFitChange={(fit) => handleDesignChange('introVideoFit', fit)}
bgColor={designMD.introVideoBgColor}
onBgColorChange={(color) => handleDesignChange('introVideoBgColor', color ?? '')}
/>
{/* ═══ Outro Video ═══ */}
<VideoUploadSimple
company={company}
label="Video de Cierre (Outro)"
description="Se usará automáticamente en plantillas que incluyan segmento de outro de marca"
videoUrl={designMD.outroVideoUrl || ''}
accentColor="#f43f5e"
onUrlChange={(url) => {
handleDesignChange('outroVideoUrl', url);
if (url) probeVideoDuration(url, 'outroDurationFrames');
}}
onClear={() => {
handleDesignChange('outroVideoUrl', '');
handleDesignChange('outroDurationFrames', 60);
}}
onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')}
showEdit={!!(designMD.outroVideoUrl && onEditAsset)}
fit={designMD.outroVideoFit}
onFitChange={(fit) => handleDesignChange('outroVideoFit', fit)}
bgColor={designMD.outroVideoBgColor}
onBgColorChange={(color) => handleDesignChange('outroVideoBgColor', color ?? '')}
/>
{/* ═══ Brand Audio ═══ */}
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Música / Jingle de Marca
</label>
{designMD.brandAudioUrl && (
<button
onClick={() => handleDesignChange('brandAudioUrl', '')}
title="Quitar audio de marca"
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">
Se incluirá como pista de fondo en plantillas de video
</p>
<div className="flex gap-3 items-start">
{/* Preview */}
<div className="w-14 h-14 rounded-lg bg-neutral-950 border border-neutral-800 flex items-center justify-center shrink-0">
{designMD.brandAudioUrl ? (
<div className="flex items-end gap-0.5 h-6">
{[3, 5, 4, 6, 3].map((h, i) => (
<div
key={i}
className="w-1 bg-violet-500 rounded-full animate-pulse"
style={{ height: `${h * 3}px`, animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
) : (
<Music size={20} className="text-neutral-600" />
)}
</div>
{/* Upload controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={designMD.brandAudioUrl || ''}
onChange={(e) => handleDesignChange('brandAudioUrl', e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://audio.mp3"
/>
<FileDropZone
compact
accept="audio/*"
label="Subir audio"
onFiles={async (files) => {
let workspacePath = '';
if (window.electronAPI) {
workspacePath = await window.electronAPI.fs.getWorkspacePath();
}
const formData = new FormData();
formData.append('file', files[0]);
try {
let res;
if (workspacePath && company.id) {
formData.append('brandId', company.id);
formData.append('workspacePath', workspacePath);
res = await fetch('/api/upload/brand', { method: 'POST', body: formData });
} else {
res = await fetch('/api/upload', { method: 'POST', body: formData });
}
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
if (data.url) handleDesignChange('brandAudioUrl', data.url);
} catch (err) {
console.error('Audio upload failed:', err);
}
}}
/>
</div>
</div>
{/* Volume slider */}
{designMD.brandAudioUrl && (
<div className="flex items-center gap-3 pt-1">
<Volume2 size={12} className="text-neutral-500 shrink-0" />
<span className="text-[10px] text-neutral-500 shrink-0">Volumen:</span>
<input
type="range"
min="0"
max="100"
value={Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}
onChange={(e) => handleDesignChange('brandAudioVolume', parseInt(e.target.value) / 100)}
className="flex-1 h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
/>
<span className="text-[10px] font-mono text-violet-300 bg-neutral-800 px-1.5 py-0.5 rounded shrink-0">
{Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}%
</span>
</div>
)}
{/* Unified Media Library as Global Drop Zone */}
<div className="flex-1 overflow-hidden">
<UnifiedMediaLibrary
brandId={company.id}
brandAssets={assets}
refreshTrigger={refreshTrigger}
onDeleteAsset={handleDeleteAsset}
onRenameAsset={handleRenameAsset}
onSelect={onSelectUnified}
selectedPath={selectedAssetId}
filterType={filterType}
filterSource={filterSource}
draggable={false} // In this view, dragging is not needed for the timeline
onDropFiles={handleBrandAssetFiles}
isUploading={uploadingBrandAsset}
/>
</div>
</div>
);
};
/* ── Simple Video Upload Card ── */
const VideoUploadSimple: React.FC<{
company: CompanyProfile;
label: string;
description: string;
videoUrl: string;
accentColor: string;
onUrlChange: (url: string) => void;
onClear: () => void;
onEdit?: () => void;
showEdit?: boolean;
fit?: 'cover' | 'contain' | 'fill';
onFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
bgColor?: string | null;
onBgColorChange?: (color: string | null) => void;
}> = ({ company, label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit, fit = 'cover', onFitChange, bgColor, onBgColorChange }) => {
const hasVideo = !!videoUrl && videoUrl.trim().length > 0;
const colorInputRef = React.useRef<HTMLInputElement>(null);
return (
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Film size={14} style={{ color: accentColor }} />
{label}
</label>
{hasVideo && (
<button
onClick={onClear}
title={`Quitar ${label}`}
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">{description}</p>
<div className="flex gap-3 items-start">
{/* Video Preview */}
<div className="w-28 h-20 rounded-lg overflow-hidden bg-neutral-950 border border-neutral-800 shrink-0 flex items-center justify-center">
{hasVideo ? (
<video
src={videoUrl}
className="w-full h-full object-cover"
muted
playsInline
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const v = e.target as HTMLVideoElement;
v.pause();
v.currentTime = 0;
}}
/>
) : (
<div className="text-neutral-600 flex flex-col items-center gap-1">
<Upload size={18} style={{ color: `${accentColor}60` }} />
<span className="text-[9px]">Sin video</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={videoUrl}
onChange={(e) => onUrlChange(e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://video.mp4"
/>
<FileDropZone
compact
accept="video/*"
label="Subir archivo"
onFiles={async (files) => {
let workspacePath = '';
if (window.electronAPI) {
workspacePath = await window.electronAPI.fs.getWorkspacePath();
}
const formData = new FormData();
formData.append('file', files[0]);
try {
let res;
if (workspacePath && company.id) {
formData.append('brandId', company.id);
formData.append('workspacePath', workspacePath);
res = await fetch('/api/upload/brand', { method: 'POST', body: formData });
} else {
res = await fetch('/api/upload', { method: 'POST', body: formData });
}
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
if (data.url) onUrlChange(data.url);
} catch (err) {
console.error('Video upload failed:', err);
}
}}
/>
{showEdit && (
<button
onClick={onEdit}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-violet-600/20 text-violet-400 hover:bg-violet-600/30 transition-colors text-xs font-semibold border border-violet-500/20"
>
<Wand2 size={14} />
Abrir en Editor Avanzado
</button>
)}
</div>
</div>
{/* Status badge */}
{hasVideo && (
<div className="flex flex-col gap-3 pt-1 border-t border-neutral-800/50">
<div className="flex items-center gap-1">
<span className="text-[10px] text-neutral-500 mr-2">Ajuste de video:</span>
{([
{ key: 'cover' as const, label: 'Cover', icon: <Maximize2 size={10} />, tip: 'Llenar pantalla' },
{ key: 'contain' as const, label: 'Contain', icon: <Minimize2 size={10} />, tip: 'Mostrar completo' },
{ key: 'fill' as const, label: 'Fill', icon: <Move size={10} />, tip: 'Estirar' },
]).map(opt => (
<button
key={opt.key}
onClick={() => onFitChange?.(opt.key)}
title={opt.tip}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-[9px] font-medium transition-all border ${
fit === opt.key
? `border-[${accentColor}]/50 bg-[${accentColor}]/15 text-[${accentColor}]`
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-neutral-200'
}`}
style={fit === opt.key ? { borderColor: `${accentColor}50`, backgroundColor: `${accentColor}15`, color: accentColor } : {}}
>
{opt.icon} {opt.label}
</button>
))}
</div>
{fit === 'contain' && onBgColorChange && (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-neutral-500 mr-2">Color de fondo:</span>
<button
onClick={() => colorInputRef.current?.click()}
title={bgColor ? `Color: ${bgColor}` : 'Seleccionar color de fondo'}
className="w-6 h-6 rounded border border-neutral-700 hover:border-neutral-500 transition-colors overflow-hidden flex items-center justify-center shrink-0"
style={{
backgroundColor: bgColor || undefined,
...(!bgColor ? {
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: '8px 8px',
backgroundPosition: '0 0, 0 4px, 4px -4px, -4px 0px',
} : {}),
}}
>
{!bgColor && <Pipette size={10} className="text-neutral-400" />}
</button>
<input
ref={colorInputRef}
type="color"
value={bgColor || '#000000'}
onChange={(e) => onBgColorChange(e.target.value)}
className="sr-only"
tabIndex={-1}
/>
<button
onClick={() => onBgColorChange(null)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all border ${
!bgColor
? `border-[${accentColor}]/50 bg-[${accentColor}]/15 text-[${accentColor}]`
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-neutral-200'
}`}
style={!bgColor ? { borderColor: `${accentColor}50`, backgroundColor: `${accentColor}15`, color: accentColor } : {}}
>
<X size={10} /> Transparente
</button>
{bgColor && <span className="text-[9px] text-neutral-500 font-mono ml-1">{bgColor}</span>}
</div>
)}
</div>
)}
</div>
);
};
+232
View File
@@ -0,0 +1,232 @@
import React, { useState } from 'react';
import { MessageSquareQuote, Plus, X, ListPlus } from 'lucide-react';
import { CompanyProfile } from '../../types';
import { CollapsibleSection } from '../ui/CollapsibleSection';
interface BrandTabVoiceProps {
company: CompanyProfile;
handleCompanyChange: (company: CompanyProfile) => void;
}
export const BrandTabVoice: React.FC<BrandTabVoiceProps> = ({ company, handleCompanyChange }) => {
const voice = company.brandVoice || {
communicationStyle: '',
toneKeywords: [],
personality: '',
exampleCopys: [],
avoidRules: [],
language: 'Español'
};
const updateVoice = (key: keyof typeof voice, value: any) => {
handleCompanyChange({
...company,
brandVoice: {
...voice,
[key]: value
}
});
};
const [newKeyword, setNewKeyword] = useState('');
const [newExample, setNewExample] = useState('');
const [newRule, setNewRule] = useState('');
const addKeyword = () => {
if (newKeyword.trim() && !voice.toneKeywords.includes(newKeyword.trim())) {
updateVoice('toneKeywords', [...voice.toneKeywords, newKeyword.trim()]);
setNewKeyword('');
}
};
const removeKeyword = (kw: string) => {
updateVoice('toneKeywords', voice.toneKeywords.filter(k => k !== kw));
};
const addExample = () => {
if (newExample.trim()) {
updateVoice('exampleCopys', [...(voice.exampleCopys || []), newExample.trim()]);
setNewExample('');
}
};
const removeExample = (index: number) => {
const list = [...(voice.exampleCopys || [])];
list.splice(index, 1);
updateVoice('exampleCopys', list);
};
const addRule = () => {
if (newRule.trim()) {
updateVoice('avoidRules', [...(voice.avoidRules || []), newRule.trim()]);
setNewRule('');
}
};
const removeRule = (index: number) => {
const list = [...(voice.avoidRules || [])];
list.splice(index, 1);
updateVoice('avoidRules', list);
};
return (
<div className="space-y-6">
{/* Basic Setup */}
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase flex items-center gap-2">
<MessageSquareQuote size={16} /> Fundamentos de Voz
</h3>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">
Estilo de Comunicación
</label>
<textarea
value={voice.communicationStyle}
onChange={(e) => updateVoice('communicationStyle', e.target.value)}
placeholder="Ej. Hablamos de tú, somos cercanos pero muy profesionales. Evitamos el lenguaje excesivamente técnico a menos que sea necesario..."
rows={3}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">
Keywords de Tono
</label>
<div className="flex flex-wrap gap-2 mb-2">
{voice.toneKeywords.map(kw => (
<span key={kw} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-violet-600/20 text-violet-300 text-xs font-medium border border-violet-500/30">
{kw}
<button onClick={() => removeKeyword(kw)} className="hover:text-white transition-colors">
<X size={12} />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addKeyword())}
placeholder="Ej. Innovador, Cercano, Experto"
className="flex-1 bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-violet-500"
/>
<button
type="button"
onClick={addKeyword}
className="px-3 py-2 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg transition-colors border border-neutral-700"
>
<Plus size={16} />
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">
Idioma Preferido
</label>
<input
type="text"
value={voice.language || 'Español'}
onChange={(e) => updateVoice('language', e.target.value)}
placeholder="Ej. Español (México), Spanglish"
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
/>
</div>
</div>
</div>
<CollapsibleSection title="Reglas y Personalidad" defaultOpen={false}>
<div className="space-y-4 p-1">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">
Personalidad de la Marca
</label>
<textarea
value={voice.personality || ''}
onChange={(e) => updateVoice('personality', e.target.value)}
placeholder="Si tu marca fuera una persona, ¿cómo sería? Ej. Un consultor experto que te explica las cosas de forma sencilla con un café en mano."
rows={2}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center justify-between">
Reglas Negativas (Qué NO hacer)
</label>
<ul className="space-y-2 mb-2">
{(voice.avoidRules || []).map((rule, i) => (
<li key={i} className="flex items-start gap-2 bg-rose-950/20 border border-rose-900/50 p-2.5 rounded-lg">
<span className="text-rose-400 text-xs mt-0.5 font-bold"></span>
<span className="flex-1 text-sm text-neutral-300">{rule}</span>
<button onClick={() => removeRule(i)} className="text-neutral-500 hover:text-rose-400">
<X size={14} />
</button>
</li>
))}
</ul>
<div className="flex gap-2">
<input
type="text"
value={newRule}
onChange={(e) => setNewRule(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addRule())}
placeholder="Ej. Nunca usar groserías o jerga infantil"
className="flex-1 bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-violet-500"
/>
<button onClick={addRule} className="px-3 py-2 bg-neutral-800 hover:bg-neutral-700 text-white rounded-lg transition-colors border border-neutral-700">
<Plus size={16} />
</button>
</div>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Ejemplos de Referencia" badge={(voice.exampleCopys || []).length} defaultOpen={true}>
<div className="space-y-4 p-1">
<p className="text-xs text-neutral-400">
Agrega ejemplos reales de buenos textos de tu marca. El modelo usará esto como referencia directa de cómo escribir.
</p>
<ul className="space-y-3">
{(voice.exampleCopys || []).map((ex, i) => (
<li key={i} className="relative group bg-neutral-900 border border-neutral-800 p-3 rounded-lg">
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => removeExample(i)} className="p-1 text-neutral-500 hover:text-rose-400 hover:bg-neutral-800 rounded">
<X size={14} />
</button>
</div>
<p className="text-sm text-neutral-300 pr-6 leading-relaxed">
"{ex}"
</p>
</li>
))}
</ul>
<div className="bg-neutral-900/50 border border-neutral-800/80 rounded-lg p-3">
<textarea
value={newExample}
onChange={(e) => setNewExample(e.target.value)}
placeholder="Pega aquí un ejemplo de copy que consideres perfecto para tu marca..."
rows={3}
className="w-full bg-transparent text-white text-sm focus:outline-none resize-none mb-2 placeholder-neutral-600"
/>
<div className="flex justify-end">
<button
onClick={addExample}
disabled={!newExample.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white text-xs font-medium rounded-lg transition-colors"
>
<ListPlus size={14} /> Añadir Ejemplo
</button>
</div>
</div>
</div>
</CollapsibleSection>
</div>
);
};
+10 -266
View File
@@ -50,19 +50,13 @@ function parseVideoPosition(pos?: string): { x: number; y: number } {
* Supports interactive drag-to-reposition for logo and content block.
*/
export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, company, aspectRatio = '9:16', onDesignChange, focusSegment, onFrameUpdate, onPlayerReady }) => {
const hasIntro = !!designMD.introVideoUrl;
const hasOutro = !!designMD.outroVideoUrl;
const introDur = designMD.introDurationFrames || 60;
const outroDur = designMD.outroDurationFrames || 60;
const contentDur = 180;
const totalDur = (hasIntro ? introDur : 0) + contentDur + (hasOutro ? outroDur : 0);
const totalDur = contentDur;
const dims = COMPOSITION_DIMS[aspectRatio] || COMPOSITION_DIMS['9:16'];
// Compute frame ranges for each segment
const introStart = 0;
const contentStart = hasIntro ? introDur : 0;
const outroStart = contentStart + contentDur;
const contentStart = 0;
// Player ref for seeking
const playerRef = useRef<any>(null);
@@ -79,24 +73,15 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
const contentY = designMD.contentY ?? 75;
// Video box positions & sizes (% of canvas)
const introX = designMD.introVideoX ?? 0;
const introY = designMD.introVideoY ?? 0;
const introW = designMD.introVideoW ?? 100;
const introH = designMD.introVideoH ?? 100;
const outroX = designMD.outroVideoX ?? 0;
const outroY = designMD.outroVideoY ?? 0;
const outroW = designMD.outroVideoW ?? 100;
const outroH = designMD.outroVideoH ?? 100;
const getOrigForElement = useCallback((element: DragElement) => {
switch (element) {
case 'logo': return { x: logoX, y: logoY };
case 'content': return { x: contentX, y: contentY };
case 'intro': return { x: introX, y: introY };
case 'outro': return { x: outroX, y: outroY };
default: return { x: 50, y: 50 };
}
}, [logoX, logoY, contentX, contentY, introX, introY, outroX, outroY]);
}, [logoX, logoY, contentX, contentY]);
const handlePointerDown = useCallback((e: React.PointerEvent, element: DragElement) => {
if (!onDesignChange) return;
@@ -129,54 +114,8 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
} else if (dragElement === 'content') {
onDesignChange('contentX', newX);
onDesignChange('contentY', newY);
} else if (dragElement === 'intro') {
onDesignChange('introVideoX', newX);
onDesignChange('introVideoY', newY);
} else if (dragElement === 'outro') {
onDesignChange('outroVideoX', newX);
onDesignChange('outroVideoY', newY);
} else if (dragElement?.startsWith('intro-resize-')) {
const corner = dragElement.replace('intro-resize-', '');
if (corner === 'br') {
onDesignChange('introVideoW', Math.max(10, Math.round(introW + deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH + deltaYPct)));
} else if (corner === 'bl') {
onDesignChange('introVideoX', Math.round(introX + deltaXPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW - deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH + deltaYPct)));
} else if (corner === 'tr') {
onDesignChange('introVideoY', Math.round(introY + deltaYPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW + deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH - deltaYPct)));
} else if (corner === 'tl') {
onDesignChange('introVideoX', Math.round(introX + deltaXPct));
onDesignChange('introVideoY', Math.round(introY + deltaYPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW - deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH - deltaYPct)));
}
setDragStart({ ...dragStart, x: e.clientX, y: e.clientY });
} else if (dragElement?.startsWith('outro-resize-')) {
const corner = dragElement.replace('outro-resize-', '');
if (corner === 'br') {
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW + deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH + deltaYPct)));
} else if (corner === 'bl') {
onDesignChange('outroVideoX', Math.round(outroX + deltaXPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW - deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH + deltaYPct)));
} else if (corner === 'tr') {
onDesignChange('outroVideoY', Math.round(outroY + deltaYPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW + deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH - deltaYPct)));
} else if (corner === 'tl') {
onDesignChange('outroVideoX', Math.round(outroX + deltaXPct));
onDesignChange('outroVideoY', Math.round(outroY + deltaYPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW - deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH - deltaYPct)));
}
setDragStart({ ...dragStart, x: e.clientX, y: e.clientY });
}
}, [dragElement, dragStart, onDesignChange, introX, introY, introW, introH, outroX, outroY, outroW, outroH]);
}, [dragElement, dragStart, onDesignChange]);
const handlePointerUp = useCallback(() => {
setDragElement(null);
@@ -190,14 +129,12 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
try {
player.pause();
let targetFrame = 0;
if (focusSegment === 'intro') targetFrame = introStart;
else if (focusSegment === 'content') targetFrame = contentStart;
else if (focusSegment === 'outro') targetFrame = outroStart;
if (focusSegment === 'content') targetFrame = contentStart;
player.seekTo(targetFrame);
} catch {
// Player may not be ready yet
}
}, [focusSegment, introStart, contentStart, outroStart]);
}, [focusSegment, contentStart]);
// Expose seek function to parent
useEffect(() => {
@@ -225,12 +162,8 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
const inputProps = useMemo(() => ({
designMD,
company,
introDur,
outroDur,
contentDur,
hasIntro,
hasOutro,
}), [designMD, company, introDur, outroDur, contentDur, hasIntro, hasOutro]);
}), [designMD, company, contentDur]);
// Whether we're in editing mode (a segment is focused)
const isEditing = !!focusSegment && focusSegment !== 'audio';
@@ -291,35 +224,7 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
</div>
)}
{/* Intro video box — only when intro is selected */}
{hasIntro && focusSegment === 'intro' && (
<VideoBoxHandle
label="Intro"
color="emerald"
x={introX}
y={introY}
w={introW}
h={introH}
isDragging={dragElement === 'intro' || !!dragElement?.startsWith('intro-resize')}
onMoveDown={(e) => handlePointerDown(e, 'intro')}
onResizeDown={(e, corner) => handlePointerDown(e, `intro-resize-${corner}` as DragElement)}
/>
)}
{/* Outro video box — only when outro is selected */}
{hasOutro && focusSegment === 'outro' && (
<VideoBoxHandle
label="Outro"
color="rose"
x={outroX}
y={outroY}
w={outroW}
h={outroH}
isDragging={dragElement === 'outro' || !!dragElement?.startsWith('outro-resize')}
onMoveDown={(e) => handlePointerDown(e, 'outro')}
onResizeDown={(e, corner) => handlePointerDown(e, `outro-resize-${corner}` as DragElement)}
/>
)}
</>
) : undefined}
>
@@ -347,19 +252,7 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
{(totalDur / 30).toFixed(1)}s · {aspectRatio} · {dims.width}×{dims.height} · 30fps
</span>
<div className="flex gap-1.5">
{hasIntro && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 border border-violet-500/20">
INTRO
</span>
)}
<span className="text-[9px] px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-400 border border-neutral-700">
CONTENIDO
</span>
{hasOutro && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 border border-violet-500/20">
OUTRO
</span>
)}
</div>
{onDesignChange && (
<span className="text-[9px] text-violet-400/50 ml-1">
@@ -376,24 +269,15 @@ export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, comp
interface SampleProps {
designMD: DesignMD;
company: CompanyProfile;
introDur: number;
outroDur: number;
contentDur: number;
hasIntro: boolean;
hasOutro: boolean;
}
const SampleComposition: React.FC<SampleProps> = ({
designMD,
company,
introDur,
outroDur,
contentDur,
hasIntro,
hasOutro,
}) => {
const contentStart = hasIntro ? introDur : 0;
const outroStart = contentStart + contentDur;
const contentStart = 0;
return (
<AbsoluteFill style={{ backgroundColor: designMD.secondaryColor }}>
@@ -407,93 +291,15 @@ const SampleComposition: React.FC<SampleProps> = ({
}}
/>
{/* ── INTRO SEQUENCE ── */}
{hasIntro && (
<Sequence from={0} durationInFrames={introDur} name="Intro">
<IntroSection designMD={designMD} company={company} />
</Sequence>
)}
{/* ── CONTENT SEQUENCE ── */}
<Sequence from={contentStart} durationInFrames={contentDur} name="Content">
<ContentSection designMD={designMD} company={company} />
</Sequence>
{/* ── OUTRO SEQUENCE ── */}
{hasOutro && (
<Sequence from={outroStart} durationInFrames={outroDur} name="Outro">
<OutroSection designMD={designMD} company={company} />
</Sequence>
)}
</AbsoluteFill>
);
};
// ═══ INTRO ═══
const IntroSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (designMD.introVideoUrl) {
const vx = designMD.introVideoX ?? 0;
const vy = designMD.introVideoY ?? 0;
const vw = designMD.introVideoW ?? 100;
const vh = designMD.introVideoH ?? 100;
return (
<AbsoluteFill>
<div style={{
position: 'absolute',
left: `${vx}%`, top: `${vy}%`,
width: `${vw}%`, height: `${vh}%`,
overflow: 'hidden',
borderRadius: vw < 100 || vh < 100 ? 8 : 0,
}}>
<Video
src={designMD.introVideoUrl}
style={{
width: '100%',
height: '100%',
objectFit: (designMD.introVideoFit || 'cover') as React.CSSProperties['objectFit'],
}}
volume={0}
/>
</div>
{/* Logo overlay on intro video */}
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${designMD.logoX ?? 5}%`,
top: `${designMD.logoY ?? 5}%`,
opacity: interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' }),
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
</AbsoluteFill>
);
}
// Fallback placeholder intro
const scale = spring({ frame, fps, config: { damping: 12, stiffness: 80 } });
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: designMD.primaryColor }}>
<div style={{ transform: `scale(${scale})`, textAlign: 'center' }}>
{designMD.logoUrl && (
<img src={designMD.logoUrl} alt="" style={{ width: 240, margin: '0 auto 24px', objectFit: 'contain' }} />
)}
<h1 style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.textColor,
fontSize: 72,
fontWeight: 'bold',
}}>
{company.name || 'INTRO'}
</h1>
</div>
</AbsoluteFill>
);
};
// ═══ CONTENT ═══
@@ -589,69 +395,7 @@ const ContentSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }>
);
};
// ═══ OUTRO ═══
const OutroSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
if (designMD.outroVideoUrl) {
const vx = designMD.outroVideoX ?? 0;
const vy = designMD.outroVideoY ?? 0;
const vw = designMD.outroVideoW ?? 100;
const vh = designMD.outroVideoH ?? 100;
return (
<AbsoluteFill>
<div style={{
position: 'absolute',
left: `${vx}%`, top: `${vy}%`,
width: `${vw}%`, height: `${vh}%`,
overflow: 'hidden',
borderRadius: vw < 100 || vh < 100 ? 8 : 0,
}}>
<Video
src={designMD.outroVideoUrl}
style={{
width: '100%',
height: '100%',
objectFit: (designMD.outroVideoFit || 'cover') as React.CSSProperties['objectFit'],
}}
volume={0}
/>
</div>
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${designMD.logoX ?? 5}%`,
top: `${designMD.logoY ?? 5}%`,
opacity: interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' }),
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
</AbsoluteFill>
);
}
// Fallback placeholder outro
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' });
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: designMD.primaryColor, opacity }}>
<div style={{ textAlign: 'center' }}>
{designMD.logoUrl && (
<img src={designMD.logoUrl} alt="" style={{ width: 180, margin: '0 auto 20px', objectFit: 'contain' }} />
)}
<p style={{
fontFamily: designMD.baseFont,
color: designMD.textColor,
fontSize: 36,
opacity: 0.8,
}}>
{company.socialLinks?.website || company.socialLinks?.instagram || company.name}
</p>
</div>
</AbsoluteFill>
);
};
// ═══ HELPERS ═══
@@ -20,12 +20,8 @@ const RATIO_INFO: Record<string, { icon: React.ReactNode; res: string; label: st
* intro → transition → content → transition → outro + audio status.
*/
export const PreviewTimeline: React.FC<PreviewTimelineProps> = ({ designMD, aspectRatio = '9:16' }) => {
const hasIntro = !!designMD.introVideoUrl;
const hasOutro = !!designMD.outroVideoUrl;
const hasAudio = !!designMD.brandAudioUrl;
const introDur = designMD.introDurationFrames || 60;
const outroDur = designMD.outroDurationFrames || 60;
const totalDur = (hasIntro ? introDur : 0) + (hasOutro ? outroDur : 0) || 1;
const totalDur = 180;
return (
@@ -42,28 +38,14 @@ export const PreviewTimeline: React.FC<PreviewTimelineProps> = ({ designMD, aspe
{/* Timeline visual */}
<div className="flex items-center gap-1.5">
{/* Intro */}
{hasIntro && (
<TimelineBlock
label="INTRO"
icon={<Film size={14} />}
duration={introDur}
color={designMD.primaryColor}
widthPercent={(introDur / totalDur) * 100}
/>
)}
{/* Outro */}
{hasOutro && (
<TimelineBlock
label="OUTRO"
icon={<Film size={14} />}
duration={outroDur}
color={designMD.primaryColor}
widthPercent={(outroDur / totalDur) * 100}
/>
)}
<TimelineBlock
label="CONTENIDO"
icon={<Film size={14} />}
duration={totalDur}
color={designMD.primaryColor}
widthPercent={100}
isMain
/>
</div>
{/* Duration */}
@@ -122,8 +122,6 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
// When chroma key is active, transparency is handled by the canvas — no CSS blend needed
if (el.chromaKeyEnabled) return 'normal';
if (!el.isBrandElement) return el.blendMode || 'normal';
if (el.content === designMD.introVideoUrl) return designMD.introBlendMode || el.blendMode || 'normal';
if (el.content === designMD.outroVideoUrl) return designMD.outroBlendMode || el.blendMode || 'normal';
return el.blendMode || 'normal';
})();
@@ -282,11 +280,7 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
style={{
width: '100%',
height: '100%',
objectFit: (el.objectFit || (() => {
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
return 'cover';
})()) as React.CSSProperties['objectFit'],
objectFit: (el.objectFit || 'cover') as React.CSSProperties['objectFit'],
opacity: opacity,
filter: filterStr,
}}
@@ -298,11 +292,7 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
style={{
width: '100%',
height: '100%',
objectFit: (el.objectFit || (() => {
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
return 'cover';
})()) as React.CSSProperties['objectFit'],
objectFit: (el.objectFit || 'cover') as React.CSSProperties['objectFit'],
opacity: opacity,
pointerEvents: 'none',
filter: filterStr,
@@ -4,6 +4,7 @@ import { ContentPiece, ContentPillar, ContentStatus, Platform, Project } from '.
import { StatusBadge } from './StatusBadge';
import { PlatformSelector } from './PlatformIcons';
import { ALL_STATUSES, STATUS_CONFIG } from '../../data/defaults';
import { CopyAssistant } from '../ai/CopyAssistant';
interface ContentDetailModalProps {
piece: ContentPiece | null;
@@ -57,6 +58,26 @@ export const ContentDetailModal: React.FC<ContentDetailModalProps> = ({
onSave(form);
};
const handleApplyCopy = (generatedCopy: string) => {
// Save original for undo if not already saved
if (!form.aiGeneratedCaption) {
update('aiGeneratedCaption', form.caption || '');
}
update('caption', generatedCopy);
};
const handleApplyHashtags = (newHashtags: string[]) => {
const combined = Array.from(new Set([...(form.hashtags || []), ...newHashtags]));
update('hashtags', combined);
};
const handleUndoCopy = () => {
if (form.aiGeneratedCaption !== undefined) {
update('caption', form.aiGeneratedCaption);
update('aiGeneratedCaption', undefined);
}
};
const handleHashtagInput = (raw: string) => {
const tags = raw
.split(/[,\s]+/)
@@ -234,6 +255,19 @@ export const ContentDetailModal: React.FC<ContentDetailModalProps> = ({
/>
</div>
{/* AI Copy Assistant */}
{form.companyId && (
<CopyAssistant
brandId={form.companyId}
platforms={form.platforms || []}
description={form.description || ''}
previousCaption={form.aiGeneratedCaption}
onApplyCopy={handleApplyCopy}
onApplyHashtags={handleApplyHashtags}
onUndo={handleUndoCopy}
/>
)}
{/* Caption */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { CompanyProfile } from '../../types';
import { Search } from 'lucide-react';
import { GeneratedMediaList } from './GeneratedMediaList';
import { UnifiedMediaLibrary } from './UnifiedMediaLibrary';
interface ContentMeshSidebarProps {
companies: CompanyProfile[];
@@ -11,11 +11,14 @@ interface ContentMeshSidebarProps {
export const ContentMeshSidebar: React.FC<ContentMeshSidebarProps> = ({ companies, filterBrandId }) => {
const [searchQuery, setSearchQuery] = useState('');
const selectedCompany = companies.find(c => c.id === filterBrandId);
const brandAssets = selectedCompany?.design?.brandAssets || [];
return (
<div className="w-80 border-r border-neutral-800 bg-neutral-900/60 flex flex-col h-full overflow-hidden shrink-0">
{/* Header */}
<div className="p-4 border-b border-neutral-800">
<h2 className="text-sm font-semibold text-white mb-4">Contenido Generado</h2>
<h2 className="text-sm font-semibold text-white mb-4">Librería de Medios</h2>
<div className="space-y-3"> {/* Search */}
<div className="relative">
@@ -35,7 +38,13 @@ export const ContentMeshSidebar: React.FC<ContentMeshSidebarProps> = ({ companie
{/* Media List */}
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
<GeneratedMediaList brandId={filterBrandId} companies={companies} searchQuery={searchQuery} draggable={true} />
<UnifiedMediaLibrary
brandId={filterBrandId}
companies={companies}
brandAssets={brandAssets}
searchQuery={searchQuery}
draggable={true}
/>
</div>
</div>
);
@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import { ChevronLeft, Play, Image as ImageIcon, FolderOpen, Instagram, Music, Youtube, Facebook, Twitter, ChevronDown, ChevronRight, Moon } from 'lucide-react';
import { ChevronLeft, Play, Image as ImageIcon, FolderOpen, Instagram, Music, Youtube, Facebook, Twitter, ChevronDown, ChevronRight, Moon, FileText } from 'lucide-react';
import { useDroppable, useDraggable } from '@dnd-kit/core';
import { useMediaResolver } from '../../hooks/useMediaResolver';
import { CompanyProfile } from '../../types';
import { MeshItemCopyModal } from './MeshItemCopyModal';
interface DailyTimelineViewProps {
dateKey: string;
@@ -41,18 +43,14 @@ export const DailyTimelineView: React.FC<DailyTimelineViewProps> = ({
const selectedBrand = filterBrandId ? companies.find(c => c.id === filterBrandId) : null;
const getBrandName = (brandId: string) => companies.find(c => c.id === brandId)?.name || 'Marca desconocida';
const [workspacePath, setWorkspacePath] = useState('');
const { getMediaUrl } = useMediaResolver();
const scrollRef = React.useRef<HTMLDivElement>(null);
const [isMorningCollapsed, setIsMorningCollapsed] = useState(true);
const [editingItem, setEditingItem] = useState<{ item: any; brandId: string } | null>(null);
const startHour = isMorningCollapsed ? 8 : 0;
useEffect(() => {
if (window.electronAPI) {
window.electronAPI.fs.getWorkspacePath().then(setWorkspacePath);
}
}, []);
useEffect(() => {
if (scrollRef.current) {
// Offset so 12 PM is clearly visible
@@ -61,12 +59,6 @@ export const DailyTimelineView: React.FC<DailyTimelineViewProps> = ({
}
}, [startHour]);
const getUrl = (absolutePath: string) => {
if (!workspacePath) return '';
const relPath = absolutePath.replace(workspacePath, '');
return `http://localhost:3000/workspace${relPath.startsWith('/') ? '' : '/'}${relPath}`;
};
const handleOpenPath = async (filePath: string) => {
if (window.electronAPI) {
await window.electronAPI.fs.showItemInFolder(filePath);
@@ -105,6 +97,12 @@ export const DailyTimelineView: React.FC<DailyTimelineViewProps> = ({
});
};
const handleSaveItem = (updatedItem: any) => {
updateItem(updatedItem.id, item => {
Object.assign(item, updatedItem);
});
};
// Metrics for header
const totalPosts = allItems.length;
let platformCounts: Record<string, number> = {};
@@ -216,10 +214,11 @@ export const DailyTimelineView: React.FC<DailyTimelineViewProps> = ({
key={item.id}
item={item}
brandName={getBrandName(item.mark_id)}
getUrl={getUrl}
getUrl={getMediaUrl}
onOpenFolder={handleOpenPath}
onToggleStatus={handleToggleStatus}
onTogglePlatform={handleTogglePlatform}
onEditCopy={() => setEditingItem({ item, brandId: item.mark_id })}
startHour={startHour}
overlapOffset={index}
/>
@@ -230,6 +229,15 @@ export const DailyTimelineView: React.FC<DailyTimelineViewProps> = ({
</div>
</div>
{editingItem && (
<MeshItemCopyModal
item={editingItem.item}
brandId={editingItem.brandId}
onClose={() => setEditingItem(null)}
onSave={handleSaveItem}
/>
)}
</div>
);
};
@@ -267,9 +275,10 @@ const TimelineItem: React.FC<{
onOpenFolder: (path: string) => void;
onToggleStatus: (id: string, currentStatus: string) => void;
onTogglePlatform: (id: string, platform: string) => void;
onEditCopy: () => void;
startHour: number;
overlapOffset: number;
}> = ({ item, brandName, getUrl, onOpenFolder, onToggleStatus, onTogglePlatform, startHour, overlapOffset }) => {
}> = ({ item, brandName, getUrl, onOpenFolder, onToggleStatus, onTogglePlatform, onEditCopy, startHour, overlapOffset }) => {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `timeline-${item.id}`,
data: {
@@ -381,7 +390,14 @@ const TimelineItem: React.FC<{
>
<FolderOpen size={10} />
</button>
<h4 className={`text-xs font-medium truncate ${isPosted ? 'text-neutral-500 line-through' : 'text-white'}`} title={item.original_name}>
<button
onClick={onEditCopy}
className={`p-1 rounded transition-colors shrink-0 ${item.caption ? 'bg-violet-600 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-white'}`}
title={item.caption ? "Editar Copy (Completado)" : "Redactar Copy"}
>
<FileText size={10} />
</button>
<h4 className={`text-xs font-medium truncate ml-1 ${isPosted ? 'text-neutral-500 line-through' : 'text-white'}`} title={item.original_name}>
{item.original_name}
</h4>
</div>
@@ -1,270 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Play, Image as ImageIcon, FolderOpen, Edit2, Check, X } from 'lucide-react';
import { useDraggable } from '@dnd-kit/core';
export interface MediaItem {
path: string;
date: string;
name: string;
type: 'video' | 'image';
}
interface GeneratedMediaListProps {
brandId?: string;
companies?: any[];
searchQuery?: string;
draggable?: boolean;
}
const DraggableMediaCard: React.FC<{
item: MediaItem;
brandId: string;
draggable: boolean;
getUrl: (path: string) => string;
onOpenFolder: (path: string) => void;
onRename: (oldPath: string, newName: string) => Promise<void>;
}> = ({ item, brandId, draggable, getUrl, onOpenFolder, onRename }) => {
const fileName = item.path.split('/').pop() || item.path;
const displayName = item.name || fileName;
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(displayName);
const [isSaving, setIsSaving] = useState(false);
// dnd-kit logic
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `media-${item.path}`,
data: {
type: 'generated-media',
brandId,
mediaItem: item
},
disabled: !draggable || isEditing,
});
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: isDragging ? 50 : 1,
opacity: isDragging ? 0.8 : 1,
} : undefined;
const handleSaveRename = async () => {
if (!editName.trim() || editName === displayName) {
setIsEditing(false);
return;
}
setIsSaving(true);
await onRename(item.path, editName);
setIsSaving(false);
setIsEditing(false);
};
return (
<div
ref={setNodeRef}
style={style}
className={`group relative bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-violet-500/50 transition-colors ${isDragging ? 'shadow-2xl shadow-violet-500/20' : ''}`}
>
{/* Thumbnail Area - acts as drag handle if draggable */}
<div
className={`aspect-video bg-neutral-950 relative flex items-center justify-center overflow-hidden ${draggable && !isEditing ? 'cursor-grab active:cursor-grabbing' : ''}`}
{...(draggable && !isEditing ? listeners : {})}
{...(draggable && !isEditing ? attributes : {})}
>
{item.type === 'video' ? (
<video
src={getUrl(item.path)}
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity pointer-events-none"
muted
loop
playsInline
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
) : (
<img
src={getUrl(item.path)}
alt={displayName}
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity pointer-events-none"
loading="lazy"
/>
)}
<div className="absolute top-2 right-2 px-2 py-1 bg-black/60 rounded text-[10px] font-medium text-white backdrop-blur-sm uppercase">
{item.type}
</div>
</div>
{/* Info Area */}
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{isEditing ? (
<div className="flex items-center gap-1">
<input
type="text"
value={editName}
onChange={e => setEditName(e.target.value)}
className="w-full bg-neutral-950 text-white text-sm px-2 py-1 rounded border border-violet-500/50 outline-none"
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') handleSaveRename();
if (e.key === 'Escape') {
setEditName(displayName);
setIsEditing(false);
}
}}
disabled={isSaving}
/>
<button onClick={handleSaveRename} disabled={isSaving} className="text-green-500 hover:bg-green-500/20 p-1 rounded">
<Check size={14} />
</button>
<button onClick={() => { setEditName(displayName); setIsEditing(false); }} disabled={isSaving} className="text-red-500 hover:bg-red-500/20 p-1 rounded">
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center gap-2 group/edit">
<p className="text-sm font-medium text-white truncate flex-1" title={displayName}>
{displayName}
</p>
<button
onClick={() => setIsEditing(true)}
className="opacity-0 group-hover/edit:opacity-100 p-1 text-neutral-400 hover:text-white transition-opacity"
title="Renombrar"
>
<Edit2 size={12} />
</button>
</div>
)}
<p className="text-xs text-neutral-500 mt-0.5">
{new Date(item.date).toLocaleString()}
</p>
</div>
{!isEditing && (
<button
onClick={() => onOpenFolder(item.path)}
title="Abrir en Finder"
className="shrink-0 p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-800 rounded-lg transition-colors"
>
<FolderOpen size={14} />
</button>
)}
</div>
</div>
</div>
);
};
export const GeneratedMediaList: React.FC<GeneratedMediaListProps> = ({ brandId, companies, searchQuery, draggable = false }) => {
const [media, setMedia] = useState<MediaItem[]>([]);
const [workspacePath, setWorkspacePath] = useState('');
const [loading, setLoading] = useState(true);
const fetchMedia = async () => {
if (window.electronAPI) {
setLoading(true);
try {
const wp = await window.electronAPI.fs.getWorkspacePath();
setWorkspacePath(wp);
let allMedia: MediaItem[] = [];
const brandsToFetch = brandId ? [brandId] : (companies?.map(c => c.id) || []);
for (const bid of brandsToFetch) {
try {
const videos = await window.electronAPI.fs.getGeneratedMedia(bid, 'video');
const images = await window.electronAPI.fs.getGeneratedMedia(bid, 'image');
allMedia = [
...allMedia,
...videos.map((v: any) => ({ ...v, type: 'video' as const, brandId: bid })),
...images.map((img: any) => ({ ...img, type: 'image' as const, brandId: bid }))
];
} catch (e) {
console.error(`Error fetching media for brand ${bid}`, e);
}
}
allMedia.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setMedia(allMedia);
} catch (err) {
console.error('Error fetching generated media:', err);
}
setLoading(false);
} else {
setMedia([]);
setLoading(false);
}
};
useEffect(() => {
fetchMedia();
}, [brandId]);
const handleOpenPath = async (filePath: string) => {
if (window.electronAPI) {
await window.electronAPI.fs.showItemInFolder(filePath);
}
};
const handleRename = async (oldPath: string, newName: string) => {
if (!window.electronAPI || !brandId) return;
// Find the item to know its type
const item = media.find(m => m.path === oldPath);
if (!item) return;
try {
const newPath = await window.electronAPI.fs.renameGeneratedMedia(brandId, item.type, oldPath, newName);
if (newPath) {
await fetchMedia();
}
} catch (e) {
console.error("Rename failed", e);
}
};
const getUrl = (absolutePath: string) => {
if (!workspacePath) return '';
const relPath = absolutePath.replace(workspacePath, '');
return `http://localhost:3000/workspace${relPath.startsWith('/') ? '' : '/'}${relPath}`;
};
if (loading) {
return <div className="text-neutral-500 text-sm p-4">Cargando...</div>;
}
const filteredMedia = media.filter(item => {
if (!searchQuery) return true;
const name = item.name || item.path.split('/').pop() || '';
return name.toLowerCase().includes(searchQuery.toLowerCase());
});
if (filteredMedia.length === 0) {
return (
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
No se encontró contenido.
</div>
);
}
return (
<div className={`grid gap-4 ${draggable ? 'grid-cols-1' : 'grid-cols-2'}`}>
{filteredMedia.map((item, idx) => (
<DraggableMediaCard
key={`${item.path}-${idx}`}
item={item}
brandId={brandId}
draggable={draggable}
getUrl={getUrl}
onOpenFolder={handleOpenPath}
onRename={handleRename}
/>
))}
</div>
);
};
@@ -0,0 +1,159 @@
import React, { useState, useEffect } from 'react';
import { X, Save, FileText, Check } from 'lucide-react';
import { CopyAssistant } from '../ai/CopyAssistant';
import { Platform } from '../../types';
interface MeshItemCopyModalProps {
item: any;
brandId: string;
onClose: () => void;
onSave: (updatedItem: any) => void;
}
export const MeshItemCopyModal: React.FC<MeshItemCopyModalProps> = ({ item, brandId, onClose, onSave }) => {
const [description, setDescription] = useState(item.description || '');
const [caption, setCaption] = useState(item.caption || '');
const [aiGeneratedCaption, setAiGeneratedCaption] = useState(item.aiGeneratedCaption || '');
const [hashtags, setHashtags] = useState<string[]>(item.hashtags || []);
const [hashtagInput, setHashtagInput] = useState(hashtags.join(', '));
// Initialize from item if we mount with existing data
useEffect(() => {
setHashtagInput((item.hashtags || []).join(', '));
}, [item]);
const handleApplyCopy = (generatedCopy: string) => {
if (!aiGeneratedCaption) {
setAiGeneratedCaption(caption);
}
setCaption(generatedCopy);
};
const handleApplyHashtags = (newHashtags: string[]) => {
const combined = Array.from(new Set([...hashtags, ...newHashtags]));
setHashtags(combined);
setHashtagInput(combined.join(', '));
};
const handleUndoCopy = () => {
if (aiGeneratedCaption !== undefined) {
setCaption(aiGeneratedCaption);
setAiGeneratedCaption('');
}
};
const handleHashtagChange = (val: string) => {
setHashtagInput(val);
const parsed = val.split(/[,\s]+/).map(t => t.replace(/^#/, '').trim()).filter(Boolean);
setHashtags(parsed);
};
const handleSave = () => {
onSave({
...item,
description,
caption,
hashtags,
aiGeneratedCaption
});
onClose();
};
const platformsToUse = (item.platforms || []) as Platform[];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-2xl bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/50 shrink-0">
<div className="flex items-center gap-2 text-white font-semibold">
<FileText size={18} className="text-violet-400" />
<span className="truncate" title={item.original_name}>
Editar Copy: {item.original_name}
</span>
</div>
<button onClick={onClose} className="p-1.5 text-neutral-500 hover:text-white hover:bg-neutral-800 rounded-lg transition-colors">
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
Descripción / Contexto
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe el contenido o idea del post (se usará como contexto para la IA)..."
rows={2}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-all resize-none"
/>
</div>
<CopyAssistant
brandId={brandId}
platforms={platformsToUse}
description={description}
previousCaption={aiGeneratedCaption}
onApplyCopy={handleApplyCopy}
onApplyHashtags={handleApplyHashtags}
onUndo={handleUndoCopy}
/>
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center justify-between">
Caption / Texto del Post
<span className="text-neutral-600 font-normal normal-case">
{caption.length} caracteres
</span>
</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="El texto final que acompañará la publicación..."
rows={4}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-all resize-none"
/>
</div>
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center justify-between">
Hashtags
<span className="text-neutral-600 font-normal normal-case">
{hashtags.length} hashtags
</span>
</label>
<input
type="text"
value={hashtagInput}
onChange={(e) => handleHashtagChange(e.target.value)}
placeholder="Escribe hashtags separados por comas..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-2.5 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-all"
/>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-neutral-800 bg-neutral-900/50 flex justify-end gap-2 shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
className="flex items-center gap-1.5 px-4 py-2 text-xs font-semibold bg-violet-600 hover:bg-violet-500 text-white rounded-lg transition-colors shadow-lg shadow-violet-900/30"
>
<Save size={14} /> Guardar Copy
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,431 @@
import React, { useEffect, useState } from 'react';
import { Play, Image as ImageIcon, FolderOpen, Edit2, Check, X, Film, Trash2, Volume2, Upload } from 'lucide-react';
import { useDraggable } from '@dnd-kit/core';
import { VideoThumbnail } from '../ui/VideoThumbnail';
import { useMediaResolver } from '../../hooks/useMediaResolver';
import { BrandAsset } from '../../types';
export interface UnifiedMediaItem {
id: string;
path: string;
url?: string;
date: string;
name: string;
type: 'video' | 'image' | 'audio';
source: 'uploaded' | 'generated';
brandId: string;
}
interface UnifiedMediaLibraryProps {
brandId?: string;
companies?: any[];
brandAssets?: BrandAsset[];
searchQuery?: string;
draggable?: boolean;
onSelect?: (item: UnifiedMediaItem) => void;
selectedPath?: string;
refreshTrigger?: number;
onDeleteAsset?: (id: string) => void;
onRenameAsset?: (id: string, newName: string) => void;
filterSource?: 'all' | 'uploaded' | 'generated';
onDropFiles?: (files: File[]) => void;
isUploading?: boolean;
}
const DraggableMediaCard: React.FC<{
item: UnifiedMediaItem;
draggable: boolean;
getUrl: (path: string) => string;
onOpenFolder: (path: string) => void;
onRename: (id: string, newName: string) => Promise<void>;
onDelete?: (id: string) => void;
onSelect?: (item: UnifiedMediaItem) => void;
isSelected?: boolean;
}> = ({ item, draggable, getUrl, onOpenFolder, onRename, onDelete, onSelect, isSelected }) => {
const displayName = item.name;
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(displayName);
const [isSaving, setIsSaving] = useState(false);
// dnd-kit logic
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `media-${item.path || item.url || item.id}`,
data: {
type: 'generated-media',
brandId: item.brandId,
mediaItem: item // For backward compatibility with the timeline drop logic
},
disabled: !draggable || isEditing,
});
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: isDragging ? 50 : 1,
opacity: isDragging ? 0.8 : 1,
} : undefined;
const handleSaveRename = async () => {
if (!editName.trim() || editName === displayName) {
setIsEditing(false);
return;
}
setIsSaving(true);
await onRename(item.source === 'generated' ? item.path : item.id, editName);
setIsSaving(false);
setIsEditing(false);
};
return (
<div
ref={setNodeRef}
style={style}
onClick={(e) => {
if (onSelect && !(e.target as HTMLElement).closest('button') && !(e.target as HTMLElement).closest('input')) {
onSelect(item);
}
}}
className={`bg-neutral-900 border rounded-xl overflow-hidden flex flex-col group relative transition-colors ${
isSelected ? 'border-violet-500 ring-1 ring-violet-500' : 'border-neutral-800'
} ${isDragging ? 'shadow-2xl shadow-violet-500/20 opacity-90' : ''} ${onSelect ? 'cursor-pointer hover:border-violet-500/50' : ''}`}
>
{/* Top right actions (Delete or Folder) */}
{!isEditing && (
<div className="absolute top-2 right-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
{item.source === 'generated' ? (
<button
onClick={(e) => { e.stopPropagation(); onOpenFolder(item.path); }}
className="bg-neutral-800/90 text-white p-1.5 rounded-lg hover:bg-neutral-700"
title="Abrir en Finder"
>
<FolderOpen size={12} />
</button>
) : (
onDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(item.id); }}
className="bg-rose-500/90 text-white p-1.5 rounded-lg hover:bg-rose-600"
title="Eliminar archivo"
>
<Trash2 size={12} />
</button>
)
)}
</div>
)}
{/* Vista previa */}
<div
className={`h-24 bg-neutral-950 relative flex items-center justify-center border-b border-neutral-800 overflow-hidden ${draggable && !isEditing ? 'cursor-grab active:cursor-grabbing' : ''}`}
{...(draggable && !isEditing ? listeners : {})}
{...(draggable && !isEditing ? attributes : {})}
>
{item.type === 'video' ? (
<VideoThumbnail src={getUrl(item.url || item.path)} alt={displayName} className="w-full h-full object-cover pointer-events-none" />
) : item.type === 'image' ? (
<img src={getUrl(item.url || item.path)} alt={displayName} className="w-full h-full object-contain p-2 pointer-events-none" loading="lazy" />
) : (
<div className="flex flex-col items-center gap-2 text-rose-500/50">
<Volume2 size={24} />
<div className="flex items-end gap-0.5 h-4">
{[2, 4, 3, 5, 2].map((h, i) => (
<div key={i} className="w-1 bg-rose-500/30 rounded-full" style={{ height: `${h * 3}px` }} />
))}
</div>
</div>
)}
{/* Etiqueta de tipo */}
<div className="absolute bottom-2 left-2 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider backdrop-blur-md bg-black/40 border border-white/10 text-white flex items-center gap-1">
{item.type === 'image' ? <ImageIcon size={8} className="text-emerald-400" /> :
item.type === 'video' ? <Film size={8} className="text-blue-400" /> :
<Play size={8} className="text-rose-400" />}
{item.type}
</div>
{/* Etiqueta de origen */}
<div className="absolute bottom-2 right-2 px-1.5 py-0.5 rounded text-[8px] font-bold uppercase tracking-wider bg-neutral-800/80 text-neutral-400 border border-neutral-700">
{item.source === 'uploaded' ? 'Subido' : 'Generado'}
</div>
</div>
{/* ID Info */}
<div className="p-3 bg-neutral-900/50 flex flex-col">
<div className="flex justify-between items-center mb-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Identificador</label>
{item.date && <span className="text-[9px] text-neutral-600">{new Date(item.date).toLocaleDateString()}</span>}
</div>
{isEditing ? (
<div className="flex gap-1">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value.replace(/[^a-zA-Z0-9-]/g, ''))}
className="flex-1 bg-neutral-950 border border-violet-500 rounded px-2 py-1 text-xs font-mono text-violet-300 focus:outline-none"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveRename();
if (e.key === 'Escape') {
setEditName(displayName);
setIsEditing(false);
}
}}
onClick={(e) => e.stopPropagation()}
disabled={isSaving}
/>
<button onClick={handleSaveRename} disabled={isSaving} className="bg-violet-600/20 text-violet-400 p-1 rounded hover:bg-violet-600/40">
<Check size={12} />
</button>
<button onClick={() => { setEditName(displayName); setIsEditing(false); }} disabled={isSaving} className="bg-red-500/20 text-red-400 p-1 rounded hover:bg-red-500/40">
<X size={12} />
</button>
</div>
) : (
<div className="flex items-center justify-between group/edit" onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}>
<span className="text-sm font-mono text-violet-300 truncate font-semibold" title={displayName}>
{displayName}
</span>
<Edit2 size={12} className="text-neutral-500 opacity-0 group-hover/edit:opacity-100 transition-opacity" />
</div>
)}
</div>
</div>
);
};
export const UnifiedMediaLibrary: React.FC<UnifiedMediaLibraryProps> = ({
brandId,
companies,
brandAssets = [],
searchQuery,
draggable = false,
onSelect,
selectedPath,
refreshTrigger = 0,
onDeleteAsset,
onRenameAsset,
filterType = 'all',
filterSource = 'all',
onDropFiles,
isUploading = false
}) => {
const [media, setMedia] = useState<UnifiedMediaItem[]>([]);
const [loading, setLoading] = useState(true);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const { getMediaUrl } = useMediaResolver();
const fetchMedia = async () => {
let allMedia: UnifiedMediaItem[] = [];
// Add uploaded brand assets
if (brandAssets && brandId) {
allMedia = [
...brandAssets.map(a => ({
id: a.id,
path: a.path || '',
url: a.url,
date: new Date().toISOString(), // Fallback date
name: a.id,
type: a.type as 'image' | 'video' | 'audio',
source: 'uploaded' as const,
brandId: brandId
}))
];
}
// Add generated media
if (window.electronAPI) {
try {
const brandsToFetch = brandId ? [brandId] : (companies?.map(c => c.id) || []);
for (const bid of brandsToFetch) {
try {
const videos = await window.electronAPI.fs.getGeneratedMedia(bid, 'video');
const images = await window.electronAPI.fs.getGeneratedMedia(bid, 'image');
allMedia = [
...allMedia,
...videos.map((v: any) => ({
id: v.path,
path: v.path,
date: v.date,
name: v.name || v.path.split('/').pop() || '',
type: 'video' as const,
source: 'generated' as const,
brandId: bid
})),
...images.map((img: any) => ({
id: img.path,
path: img.path,
date: img.date,
name: img.name || img.path.split('/').pop() || '',
type: 'image' as const,
source: 'generated' as const,
brandId: bid
}))
];
} catch (e) {
console.error(`Error fetching media for brand ${bid}`, e);
}
}
} catch (err) {
console.error('Error fetching generated media:', err);
}
}
// Sort by date descending
allMedia.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setMedia(allMedia);
setLoading(false);
};
useEffect(() => {
fetchMedia();
}, [brandId, refreshTrigger, brandAssets]);
const handleOpenPath = async (filePath: string) => {
if (window.electronAPI) {
await window.electronAPI.fs.showItemInFolder(filePath);
}
};
const handleRename = async (oldIdOrPath: string, newName: string) => {
const item = media.find(m => m.id === oldIdOrPath || m.path === oldIdOrPath);
if (!item) return;
if (item.source === 'generated') {
if (!window.electronAPI || !brandId) return;
try {
const newPath = await window.electronAPI.fs.renameGeneratedMedia(brandId, item.type, item.path, newName);
if (newPath) {
await fetchMedia();
}
} catch (e) {
console.error("Rename failed", e);
}
} else {
// It's an uploaded asset
if (onRenameAsset) {
onRenameAsset(item.id, newName);
}
}
};
if (loading) {
return <div className="text-neutral-500 text-sm p-4">Cargando librería...</div>;
}
const filteredMedia = media.filter(item => {
// Search query filter
if (searchQuery) {
const name = item.name.toLowerCase();
if (!name.includes(searchQuery.toLowerCase())) return false;
}
// Type filter
if (filterType !== 'all' && item.type !== filterType) return false;
// Source filter
if (filterSource !== 'all' && item.source !== filterSource) return false;
return true;
});
const handleDragOver = (e: React.DragEvent) => {
if (!onDropFiles) return;
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
};
const handleDragEnter = (e: React.DragEvent) => {
if (!onDropFiles) return;
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
if (!onDropFiles) return;
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
};
const handleDrop = (e: React.DragEvent) => {
if (!onDropFiles) return;
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
onDropFiles(Array.from(e.dataTransfer.files));
}
};
return (
<div
className={`relative min-h-[200px] h-full transition-colors rounded-xl overflow-y-auto custom-scrollbar flex flex-col ${
isDraggingOver ? 'bg-violet-900/10 border-2 border-dashed border-violet-500' : ''
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Uploading Overlay */}
{isUploading && (
<div className="absolute inset-0 z-50 bg-black/40 backdrop-blur-sm flex flex-col items-center justify-center rounded-xl border border-violet-500/30">
<div className="w-8 h-8 border-2 border-violet-500/30 border-t-violet-500 rounded-full animate-spin mb-3" />
<p className="text-violet-300 text-sm font-medium animate-pulse">Subiendo archivo(s)...</p>
</div>
)}
{/* Dragging Overlay */}
{isDraggingOver && !isUploading && (
<div className="absolute inset-0 z-40 bg-violet-500/5 backdrop-blur-[2px] flex items-center justify-center pointer-events-none rounded-xl">
<div className="bg-neutral-900 border border-violet-500/50 shadow-2xl shadow-violet-500/20 px-6 py-4 rounded-xl flex items-center gap-3 transform scale-110 transition-transform">
<Upload size={24} className="text-violet-400" />
<div>
<h3 className="text-white font-bold">Soltar aquí</h3>
<p className="text-xs text-violet-300">Añadir a la librería</p>
</div>
</div>
</div>
)}
{filteredMedia.length === 0 ? (
<div className="h-full w-full flex flex-col items-center justify-center text-center p-8">
<ImageIcon size={48} className="text-neutral-800 mb-4" />
<p className="text-neutral-400 font-medium">Librería vacía</p>
<p className="text-neutral-500 text-xs mt-1 max-w-sm">
{onDropFiles ? "Arrastra y suelta imágenes, videos o audios aquí para añadirlos a los recursos de tu marca." : "No se encontraron archivos con los filtros actuales."}
</p>
</div>
) : (
<div className={`grid gap-4 p-1 flex-1 ${draggable ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3'}`}>
{filteredMedia.map((item, idx) => (
<DraggableMediaCard
key={`${item.id}-${idx}`}
item={item}
draggable={draggable}
getUrl={getMediaUrl}
onOpenFolder={handleOpenPath}
onRename={handleRename}
onDelete={onDeleteAsset}
onSelect={onSelect}
isSelected={selectedPath === item.path || selectedPath === item.id}
/>
))}
</div>
)}
{/* Visual cue when files exist */}
{filteredMedia.length > 0 && onDropFiles && (
<div className="py-8 flex flex-col items-center justify-center text-neutral-600 opacity-50 border-t border-dashed border-neutral-800/50 mt-4 mx-4">
<Upload size={24} className="mb-2" />
<p className="text-sm font-medium">Puedes arrastrar más archivos aquí</p>
</div>
)}
</div>
);
};
@@ -34,8 +34,6 @@ function resolveBrandValue(source: BrandSource | undefined, brand: CompanyProfil
case 'brand-name': return brand.name || brand.design.brandName || '';
case 'tagline': return brand.tagline || '';
case 'logo': return brand.design.logoUrl || '';
case 'intro-video': return brand.design.introVideoUrl || '';
case 'outro-video': return brand.design.outroVideoUrl || '';
case 'primary-color': return brand.design.primaryColor;
case 'secondary-color': return brand.design.secondaryColor;
case 'instagram': return brand.socialLinks?.instagram || '';
@@ -57,8 +57,6 @@ function resolveBrandPreview(field: TemplateField, designMD: DesignMD, company:
case 'twitter': return company.socialLinks?.x || '@x';
case 'youtube': return company.socialLinks?.youtube || 'YouTube';
case 'website': return company.socialLinks?.website || 'www.example.com';
case 'intro-video': return designMD.introVideoUrl || '';
case 'outro-video': return designMD.outroVideoUrl || '';
default: return field.content;
}
}
@@ -16,17 +16,16 @@ const NATURE_CONFIG: Record<TemplateFieldNature, { label: string; color: string;
'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: 'audio', label: 'Audio', icon: <Zap size={10} /> },
{ value: 'shape', label: 'Forma', icon: <Pentagon size={10} /> },
{ value: 'sticker', label: 'Sticker', icon: <Zap size={10} /> },
];
/** Brand sources categorized by field type */
const BRAND_SOURCES_MAP: Record<TemplateFieldType, { value: BrandSource; label: string }[]> = {
const BRAND_SOURCES_MAP: Record<string, { value: BrandSource; label: string }[]> = {
'text': [
{ value: 'brand-name', label: 'Nombre de Marca' },
{ value: 'tagline', label: 'Tagline' },
@@ -38,10 +37,13 @@ const BRAND_SOURCES_MAP: Record<TemplateFieldType, { value: BrandSource; label:
],
'image': [
{ value: 'logo', label: 'Logo' },
{ value: 'brand-asset', label: 'Contenido de Marca (ID)' },
],
'video': [
{ value: 'intro-video', label: 'Video Intro de Marca' },
{ value: 'outro-video', label: 'Video Outro de Marca' },
{ value: 'brand-asset', label: 'Contenido de Marca (ID)' },
],
'audio': [
{ value: 'brand-asset', label: 'Contenido de Marca (ID)' },
],
'color': [
{ value: 'primary-color', label: 'Color Primario' },
@@ -201,20 +203,41 @@ export const FieldConfigPanel: React.FC = () => {
{/* ── 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[field.type] || []).map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<div className="space-y-3">
<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[field.type] || []).map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{/* If brandSource === 'brand-asset', ask for the ID */}
{field.brandSource === 'brand-asset' && (
<div className="space-y-1 bg-violet-900/10 p-2 rounded-lg border border-violet-500/20">
<label className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold flex items-center gap-1">
<Hash size={8} /> Identificador del Archivo
</label>
<input
type="text"
value={field.brandAssetId || ''}
onChange={(e) => updateField(field.id, { brandAssetId: e.target.value })}
placeholder="ej. logo-animado"
className="w-full bg-neutral-900 border border-violet-500/30 rounded-lg px-2 py-1.5 text-xs text-violet-100 font-mono focus:border-violet-500 focus:outline-none"
/>
<p className="text-[8px] text-violet-400/70 leading-tight">
Escribe el ID exacto del archivo que subiste en la pestaña de Marca.
</p>
</div>
)}
</div>
)}
@@ -46,9 +46,8 @@ export const SegmentCard: React.FC<SegmentCardProps> = ({
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 brandVideoUrl = undefined;
const hasBrandVideo = false;
const brandMissing = isBrand && !hasBrandVideo;
const borderColor = isBrand ? '#8b5cf6' : '#3b82f6';
@@ -37,15 +37,10 @@ export const SegmentVideoFrame: React.FC<SegmentVideoFrameProps> = ({
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');
const fit = scene.segmentVideoFit ?? 'cover';
// Brand video URL
const videoUrl = isBrand
? (isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl)
: undefined;
const hasVideo = !!videoUrl;
const videoUrl = undefined;
const hasVideo = false;
const dimensions = getAspectDimensions(aspectRatio);
+221
View File
@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Key, Link2, Sparkles, AlertCircle, CheckCircle2, Mic } from 'lucide-react';
import { AIProviderSettings } from '../../types';
interface AISettingsPanelProps {
onClose: () => void;
}
export const AISettingsPanel: React.FC<AISettingsPanelProps> = ({ onClose }) => {
const [settings, setSettings] = useState<AIProviderSettings>({
litellmBaseUrl: '',
apiKey: '',
model: '',
temperature: 0.7,
maxTokens: 500
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
useEffect(() => {
const loadSettings = async () => {
try {
if (window.electronAPI?.ai) {
const data = await window.electronAPI.ai.getSettings();
if (data) {
setSettings({
litellmBaseUrl: data.litellmBaseUrl || '',
apiKey: data.apiKey || '',
model: data.model || '',
temperature: data.temperature ?? 0.7,
maxTokens: data.maxTokens ?? 500
});
}
}
} catch (err) {
console.error('Failed to load AI settings', err);
} finally {
setIsLoading(false);
}
};
loadSettings();
}, []);
const handleSave = async () => {
setIsSaving(true);
try {
if (window.electronAPI?.ai) {
await window.electronAPI.ai.saveSettings(settings);
}
onClose();
} catch (err) {
console.error('Failed to save AI settings', err);
} finally {
setIsSaving(false);
}
};
const handleTestConnection = async () => {
if (!settings.litellmBaseUrl || !settings.apiKey) return;
// Save first to ensure the backend uses the latest keys
await window.electronAPI?.ai?.saveSettings(settings);
setTestStatus('testing');
try {
const res = await window.electronAPI?.ai?.testConnection();
if (res?.success) {
setTestStatus('success');
setTestMessage('Conexión exitosa');
} else {
setTestStatus('error');
setTestMessage(res?.error || 'Error de conexión');
}
} catch (err: any) {
setTestStatus('error');
setTestMessage(err.message || 'Error desconocido');
}
};
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-8 h-8 border-2 border-violet-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-lg bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/50">
<div className="flex items-center gap-2 text-white font-semibold">
<Sparkles size={18} className="text-violet-400" />
<span>Configuración de Inteligencia Artificial</span>
</div>
<button
onClick={onClose}
className="p-1.5 text-neutral-500 hover:text-white hover:bg-neutral-800 rounded-lg transition-colors"
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold tracking-widest text-neutral-400 uppercase">
Generador de Textos (LiteLLM / OpenAI)
</h3>
</div>
<p className="text-xs text-neutral-500">
Configura tu proveedor de IA compatible con OpenAI. Recomendamos usar un proxy LiteLLM local para manejar múltiples modelos.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-400 mb-1.5 flex items-center gap-1.5">
<Link2 size={12} /> URL Base
</label>
<input
type="text"
value={settings.litellmBaseUrl}
onChange={(e) => setSettings({ ...settings, litellmBaseUrl: e.target.value })}
placeholder="Ej. http://localhost:4000/v1 o https://api.openai.com/v1"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-400 mb-1.5 flex items-center gap-1.5">
<Key size={12} /> API Key
</label>
<input
type="password"
value={settings.apiKey}
onChange={(e) => setSettings({ ...settings, apiKey: e.target.value })}
placeholder="sk-..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-400 mb-1.5">
Modelo
</label>
<input
type="text"
value={settings.model}
onChange={(e) => setSettings({ ...settings, model: e.target.value })}
placeholder="Ej. gpt-4o, claude-3-sonnet, llama3"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-colors"
/>
</div>
<div className="pt-2">
<button
type="button"
onClick={handleTestConnection}
disabled={!settings.litellmBaseUrl || !settings.apiKey || testStatus === 'testing'}
className="px-3 py-1.5 text-xs font-medium bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 text-white rounded-md transition-colors"
>
{testStatus === 'testing' ? 'Probando...' : 'Probar Conexión'}
</button>
{testStatus === 'success' && (
<div className="mt-2 text-xs text-emerald-400 flex items-center gap-1.5 bg-emerald-950/30 p-2 rounded border border-emerald-900/50">
<CheckCircle2 size={14} /> {testMessage}
</div>
)}
{testStatus === 'error' && (
<div className="mt-2 text-xs text-rose-400 flex items-center gap-1.5 bg-rose-950/30 p-2 rounded border border-rose-900/50">
<AlertCircle size={14} /> {testMessage}
</div>
)}
</div>
</div>
</div>
<div className="h-px bg-neutral-800" />
{/* GROQ Placeholder */}
<div className="space-y-4 opacity-50 pointer-events-none">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold tracking-widest text-neutral-400 uppercase flex items-center gap-1.5">
<Mic size={14} /> Transcripción de Audio (Próximamente)
</h3>
</div>
<p className="text-xs text-neutral-500">
La configuración de Groq Whisper actualmente se maneja desde el archivo .env del servidor.
</p>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-neutral-800 bg-neutral-900/50 flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-1.5 px-4 py-2 text-xs font-semibold bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white rounded-lg transition-colors shadow-lg shadow-violet-900/30"
>
<Save size={14} />
{isSaving ? 'Guardando...' : 'Guardar Configuración'}
</button>
</div>
</div>
</div>
);
};
+1 -11
View File
@@ -148,17 +148,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
let offset = 0;
return template.scenes.map(scene => {
let actualDuration = scene.durationSeconds;
if (scene.segmentSource === 'brand') {
if (scene.type === 'intro') {
if (!designMD.introVideoUrl) { actualDuration = 0; }
else { actualDuration = (designMD.introDurationFrames || (scene.durationSeconds * 30)) / 30; }
}
if (scene.type === 'outro') {
if (!designMD.outroVideoUrl) { actualDuration = 0; }
else { actualDuration = (designMD.outroDurationFrames || (scene.durationSeconds * 30)) / 30; }
}
} else if (videoDurations && videoDurations[scene.id]) {
if (videoDurations && videoDurations[scene.id]) {
// Use actual video duration if user uploaded one
actualDuration = videoDurations[scene.id];
}
+1 -3
View File
@@ -15,9 +15,7 @@ export const StudioTopBar: React.FC<StudioTopBarProps> = ({ setCurrentStep }) =>
const titleOverride = editingBrandAsset ? (
<span>Editando Activo: <span className="text-violet-400">{
editingBrandAsset.type === 'logoUrl' ? 'Logo' :
editingBrandAsset.type === 'introVideoUrl' ? 'Video Intro' :
'Video Outro'
editingBrandAsset.type === 'logoUrl' ? 'Logo' : 'Activo'
}</span></span>
) : undefined;
+1 -124
View File
@@ -65,86 +65,6 @@ export const TimelineLayerLabels: React.FC<TimelineLayerLabelsProps> = ({
const hasLogo = !!designMD?.logoUrl;
const hasFrame = (designMD?.frameThickness ?? 0) > 0;
// === Intro/Outro toggle helpers ===
const introEl = timelineElements.find(el => el.isBrandElement && el.content === designMD?.introVideoUrl);
const outroEl = timelineElements.find(el => el.isBrandElement && el.content === designMD?.outroVideoUrl);
const hasIntroVideo = !!designMD?.introVideoUrl;
const hasOutroVideo = !!designMD?.outroVideoUrl;
const isIntroActive = !!introEl;
const isOutroActive = !!outroEl;
const toggleIntro = useCallback(() => {
if (!designMD?.introVideoUrl) return;
const introDur = designMD.introDurationFrames || 60;
if (isIntroActive && introEl) {
// Deactivate: remove intro, shift content back
const introLen = introEl.endFrame - introEl.startFrame;
setTimelineElements(prev => prev
.filter(el => el.id !== introEl.id)
.map(el => el.isBrandElement ? el : {
...el,
startFrame: Math.max(0, el.startFrame - introLen),
endFrame: Math.max(1, el.endFrame - introLen),
})
);
} else {
// Activate: shift content forward, add intro
setTimelineElements(prev => [
...prev.map(el => el.isBrandElement ? el : {
...el,
startFrame: el.startFrame + introDur,
endFrame: el.endFrame + introDur,
}),
{
id: `el-intro-${Date.now()}`,
layerId: 'brand-layer',
type: 'video' as const,
content: designMD.introVideoUrl!,
isBrandElement: true,
brandDisplayMode: 'fullscreen' as const,
startFrame: 0,
endFrame: introDur,
x: designMD.introVideoX ?? 0,
y: designMD.introVideoY ?? 0,
w: designMD.introVideoW ?? 100,
h: designMD.introVideoH ?? 100,
blendMode: designMD.introBlendMode || 'normal',
},
]);
}
}, [designMD, isIntroActive, introEl, setTimelineElements]);
const toggleOutro = useCallback(() => {
if (!designMD?.outroVideoUrl) return;
const outroDur = designMD.outroDurationFrames || 60;
if (isOutroActive && outroEl) {
// Deactivate: just remove outro
setTimelineElements(prev => prev.filter(el => el.id !== outroEl.id));
} else {
// Activate: add outro after all content
const maxFrame = Math.max(...timelineElements.filter(el => !el.isBrandElement || el.content !== designMD.outroVideoUrl).map(el => el.endFrame), 0);
setTimelineElements(prev => [
...prev,
{
id: `el-outro-${Date.now()}`,
layerId: 'brand-layer',
type: 'video' as const,
content: designMD.outroVideoUrl!,
isBrandElement: true,
brandDisplayMode: 'fullscreen' as const,
startFrame: maxFrame,
endFrame: maxFrame + outroDur,
x: designMD.outroVideoX ?? 0,
y: designMD.outroVideoY ?? 0,
w: designMD.outroVideoW ?? 100,
h: designMD.outroVideoH ?? 100,
blendMode: designMD.outroBlendMode || 'normal',
},
]);
}
}, [designMD, isOutroActive, outroEl, timelineElements, setTimelineElements]);
return (
<div className={`${outputFormat === 'image' ? 'flex-1 w-full' : 'w-48'} border-r border-neutral-800 bg-neutral-950/80 z-20 flex flex-col overflow-y-auto hide-scrollbar shrink-0`}>
@@ -324,50 +244,7 @@ export const TimelineLayerLabels: React.FC<TimelineLayerLabelsProps> = ({
{/* Brand layer: show intro/outro/logo/frame toggles */}
{layer.type === 'brand' ? (
<>
{hasIntroVideo && (
<div
className={`h-8 pl-5 pr-3 flex items-center gap-2 cursor-pointer transition-colors border-l-2 border-transparent
${isIntroActive ? 'hover:bg-neutral-900/50 text-neutral-400' : 'hover:bg-neutral-900/50 text-neutral-600'}
`}
onClick={(e) => { e.stopPropagation(); toggleIntro(); }}
>
<button
className={`p-0.5 rounded transition-colors ${
isIntroActive ? 'text-emerald-400 hover:text-emerald-300' : 'text-neutral-600 hover:text-neutral-400'
}`}
title={isIntroActive ? 'Desactivar Intro' : 'Activar Intro'}
>
{isIntroActive ? <ToggleRight size={14} /> : <ToggleLeft size={14} />}
</button>
<Video size={10} className={isIntroActive ? 'text-emerald-400' : 'text-neutral-600'} />
<span className={`text-[9px] font-semibold uppercase tracking-wider ${isIntroActive ? 'text-emerald-400' : 'text-neutral-600 line-through'}`}>Intro</span>
{isIntroActive && introEl && (
<span className="text-[8px] text-neutral-600 ml-auto font-mono">{((introEl.endFrame - introEl.startFrame) / 30).toFixed(1)}s</span>
)}
</div>
)}
{hasOutroVideo && (
<div
className={`h-8 pl-5 pr-3 flex items-center gap-2 cursor-pointer transition-colors border-l-2 border-transparent
${isOutroActive ? 'hover:bg-neutral-900/50 text-neutral-400' : 'hover:bg-neutral-900/50 text-neutral-600'}
`}
onClick={(e) => { e.stopPropagation(); toggleOutro(); }}
>
<button
className={`p-0.5 rounded transition-colors ${
isOutroActive ? 'text-rose-400 hover:text-rose-300' : 'text-neutral-600 hover:text-neutral-400'
}`}
title={isOutroActive ? 'Desactivar Outro' : 'Activar Outro'}
>
{isOutroActive ? <ToggleRight size={14} /> : <ToggleLeft size={14} />}
</button>
<Video size={10} className={isOutroActive ? 'text-rose-400' : 'text-neutral-600'} />
<span className={`text-[9px] font-semibold uppercase tracking-wider ${isOutroActive ? 'text-rose-400' : 'text-neutral-600 line-through'}`}>Outro</span>
{isOutroActive && outroEl && (
<span className="text-[8px] text-neutral-600 ml-auto font-mono">{((outroEl.endFrame - outroEl.startFrame) / 30).toFixed(1)}s</span>
)}
</div>
)}
{/* Logo toggle */}
{hasLogo && (
<div
+255
View File
@@ -0,0 +1,255 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { Play, Pause, Volume2, VolumeX, Maximize, SkipBack, SkipForward } from 'lucide-react';
interface CustomVideoPlayerProps {
src: string;
autoPlay?: boolean;
className?: string;
/** Optional poster image URL */
poster?: string;
}
const formatTime = (seconds: number): string => {
if (!isFinite(seconds) || isNaN(seconds)) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
export const CustomVideoPlayer: React.FC<CustomVideoPlayerProps> = ({ src, autoPlay = false, className, poster }) => {
const videoRef = useRef<HTMLVideoElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [muted, setMuted] = useState(false);
const [showControls, setShowControls] = useState(true);
const [seeking, setSeeking] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
// Auto-hide controls after 3s of no mouse movement
const resetHideTimer = useCallback(() => {
setShowControls(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(() => setShowControls(false), 3000);
}
}, [playing]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTimeUpdate = () => setCurrentTime(video.currentTime);
const onDurationChange = () => setDuration(video.duration);
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onEnded = () => { setPlaying(false); setShowControls(true); };
video.addEventListener('timeupdate', onTimeUpdate);
video.addEventListener('durationchange', onDurationChange);
video.addEventListener('loadedmetadata', onDurationChange);
video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause);
video.addEventListener('ended', onEnded);
return () => {
video.removeEventListener('timeupdate', onTimeUpdate);
video.removeEventListener('durationchange', onDurationChange);
video.removeEventListener('loadedmetadata', onDurationChange);
video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause);
video.removeEventListener('ended', onEnded);
};
}, [src]);
useEffect(() => {
if (autoPlay && videoRef.current) {
videoRef.current.play().catch(() => {});
}
}, [src, autoPlay]);
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play().catch(() => {});
} else {
video.pause();
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setMuted(video.muted);
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const bar = progressRef.current;
const video = videoRef.current;
if (!bar || !video || !duration) return;
const rect = bar.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const pct = x / rect.width;
video.currentTime = pct * duration;
setCurrentTime(video.currentTime);
};
const handleProgressPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
setSeeking(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
handleSeek(e as any);
};
const handleProgressPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!seeking) return;
handleSeek(e as any);
};
const handleProgressPointerUp = () => {
setSeeking(false);
};
const skip = (seconds: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.max(0, Math.min(video.currentTime + seconds, duration));
};
const toggleFullscreen = () => {
const container = containerRef.current;
if (!container) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen().catch(() => {});
}
};
return (
<div
ref={containerRef}
className={`relative group/player bg-black rounded-xl overflow-hidden select-none ${className || ''}`}
onMouseMove={resetHideTimer}
onMouseLeave={() => playing && setShowControls(false)}
onMouseEnter={() => setShowControls(true)}
>
{/* Video Element — no native controls */}
<video
ref={videoRef}
src={src}
poster={poster}
className="w-full h-full object-contain cursor-pointer"
playsInline
muted={muted}
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
/>
{/* Big centered play button when paused */}
{!playing && (
<button
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center bg-black/30 transition-opacity"
title="Reproducir"
>
<div className="w-16 h-16 rounded-full bg-violet-600/90 flex items-center justify-center shadow-2xl shadow-violet-900/50 hover:bg-violet-500 transition-colors backdrop-blur-sm">
<Play size={28} className="text-white ml-1" fill="currentColor" />
</div>
</button>
)}
{/* Bottom controls overlay */}
<div
className={`absolute bottom-0 left-0 right-0 transition-opacity duration-300 ${
showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
{/* Gradient fade */}
<div className="bg-gradient-to-t from-black/90 via-black/50 to-transparent pt-12 pb-3 px-4 space-y-2">
{/* Progress bar */}
<div
ref={progressRef}
className="h-1.5 bg-white/10 rounded-full cursor-pointer group/progress relative hover:h-2 transition-all"
onPointerDown={handleProgressPointerDown}
onPointerMove={handleProgressPointerMove}
onPointerUp={handleProgressPointerUp}
>
{/* Buffered / Filled */}
<div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-violet-500 to-fuchsia-500 rounded-full"
style={{ width: `${progress}%` }}
/>
{/* Thumb */}
<div
className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-md shadow-black/40 opacity-0 group-hover/progress:opacity-100 transition-opacity"
style={{ left: `calc(${progress}% - 7px)` }}
/>
</div>
{/* Controls row */}
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={togglePlay}
className="text-white hover:text-violet-400 transition-colors"
title={playing ? 'Pausar' : 'Reproducir'}
>
{playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
{/* Skip back 5s */}
<button
onClick={() => skip(-5)}
className="text-white/60 hover:text-white transition-colors"
title="Retroceder 5s"
>
<SkipBack size={14} />
</button>
{/* Skip forward 5s */}
<button
onClick={() => skip(5)}
className="text-white/60 hover:text-white transition-colors"
title="Avanzar 5s"
>
<SkipForward size={14} />
</button>
{/* Time */}
<span className="text-[11px] text-white/70 font-mono tabular-nums min-w-[80px]">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
{/* Spacer */}
<div className="flex-1" />
{/* Mute */}
<button
onClick={toggleMute}
className="text-white/60 hover:text-white transition-colors"
title={muted ? 'Activar sonido' : 'Silenciar'}
>
{muted ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="text-white/60 hover:text-white transition-colors"
title="Pantalla completa"
>
<Maximize size={16} />
</button>
</div>
</div>
</div>
</div>
);
};
+55
View File
@@ -0,0 +1,55 @@
import React, { useState, useEffect, useRef } from 'react';
import { Film } from 'lucide-react';
export const VideoThumbnail: React.FC<{ src: string; alt: string; className?: string }> = ({ src, alt, className }) => {
const [poster, setPoster] = useState<string | null>(null);
const attempted = useRef(false);
useEffect(() => {
if (attempted.current || !src) return;
attempted.current = true;
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.muted = true;
video.preload = 'metadata';
video.src = src;
const handleSeeked = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
setPoster(canvas.toDataURL('image/jpeg', 0.8));
}
} catch {
// CORS or other error — fall back to icon
}
video.remove();
};
video.addEventListener('seeked', handleSeeked, { once: true });
video.addEventListener('loadeddata', () => {
video.currentTime = 0.1; // seek to first frame
}, { once: true });
video.addEventListener('error', () => video.remove(), { once: true });
return () => {
video.removeEventListener('seeked', handleSeeked);
video.remove();
};
}, [src]);
if (poster) {
return <img src={poster} alt={alt} className={className} />;
}
return (
<div className={`w-full h-full flex items-center justify-center bg-neutral-950 ${className || ''}`}>
<Film size={28} className="text-blue-500/40" />
</div>
);
};