feat: AI Brand Voice Translator integration and Mesh Content fix
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user