Initial commit — Bradly branding editor platform

This commit is contained in:
2026-06-02 03:27:03 -05:00
commit b135a70cc7
180 changed files with 43160 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import {
AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
} from 'lucide-react';
interface AlignmentToolsProps {
/** Generic alignment callback — receives the axis values to set */
onAlign: (updates: { x?: number; y?: number }) => void;
}
/**
* AlignmentTools — Quick-align element to canvas edges or center.
* All values are percentages (0-100) matching the x/y coordinate system.
*
* Generic interface: works with any data model (TimelineElement, ExpressField, etc.)
*/
export const AlignmentTools: React.FC<AlignmentToolsProps> = ({ onAlign }) => {
const alignActions = [
{
icon: <AlignHorizontalJustifyStart size={14} />,
label: 'Alinear Izquierda',
action: () => onAlign({ x: 5 }),
},
{
icon: <AlignHorizontalJustifyCenter size={14} />,
label: 'Centrar Horizontal',
action: () => onAlign({ x: 50 }),
},
{
icon: <AlignHorizontalJustifyEnd size={14} />,
label: 'Alinear Derecha',
action: () => onAlign({ x: 95 }),
},
{
icon: <AlignVerticalJustifyStart size={14} />,
label: 'Alinear Arriba',
action: () => onAlign({ y: 5 }),
},
{
icon: <AlignVerticalJustifyCenter size={14} />,
label: 'Centrar Vertical',
action: () => onAlign({ y: 50 }),
},
{
icon: <AlignVerticalJustifyEnd size={14} />,
label: 'Alinear Abajo',
action: () => onAlign({ y: 95 }),
},
];
return (
<div className="space-y-1.5">
<span className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Alinear</span>
<div className="flex gap-1">
{alignActions.map((a) => (
<button
key={a.label}
onClick={a.action}
title={a.label}
className="flex-1 py-1.5 rounded-md bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-white hover:border-neutral-600 transition-all flex items-center justify-center"
>
{a.icon}
</button>
))}
</div>
{/* Quick Center Both */}
<button
onClick={() => onAlign({ x: 50, y: 50 })}
title="Centrar en Canvas"
className="w-full py-1.5 rounded-md bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-violet-300 hover:border-violet-500/40 transition-all text-[10px] font-medium"
>
Centrar en Canvas
</button>
</div>
);
};
+50
View File
@@ -0,0 +1,50 @@
import React, { useState, useEffect } from 'react';
import { Check, Loader2, Save } from 'lucide-react';
interface AutoSaveIndicatorProps {
/** Timestamp of last save (Date.now()) */
lastSaved: number | null;
/** Whether a save is currently in progress */
isSaving?: boolean;
}
/**
* AutoSaveIndicator — Shows a subtle indicator of auto-save status.
* Displays a checkmark when recently saved, fades after a few seconds.
*/
export const AutoSaveIndicator: React.FC<AutoSaveIndicatorProps> = ({
lastSaved,
isSaving = false,
}) => {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (lastSaved) {
setVisible(true);
const timer = setTimeout(() => setVisible(false), 3000);
return () => clearTimeout(timer);
}
}, [lastSaved]);
if (!visible && !isSaving) return null;
return (
<div
className={`absolute bottom-3 left-3 z-20 flex items-center gap-1.5 px-2 py-1 rounded-md
bg-neutral-950/60 backdrop-blur-sm border border-neutral-800/30
transition-opacity duration-500 ${visible || isSaving ? 'opacity-100' : 'opacity-0'}`}
>
{isSaving ? (
<>
<Loader2 size={10} className="text-amber-400 animate-spin" />
<span className="text-[8px] text-amber-400 font-medium">Guardando...</span>
</>
) : (
<>
<Check size={10} className="text-emerald-400" />
<span className="text-[8px] text-emerald-400/80 font-medium">Guardado</span>
</>
)}
</div>
);
};
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
import { Lock, Unlock, Eye, EyeOff, Trash2, Copy } from 'lucide-react';
import { TimelineElement } from '../../types';
interface BulkActionsBarProps {
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
setSelectedElementId: (id: string | null) => void;
}
/**
* BulkActionsBar — Quick buttons for bulk operations on all user elements.
* Lock all, unlock all, duplicate all visible, delete all unlocked, etc.
*/
export const BulkActionsBar: React.FC<BulkActionsBarProps> = ({
timelineElements,
setTimelineElements,
setSelectedElementId,
}) => {
const userElements = timelineElements.filter(e => !e.isBrandElement);
const lockedCount = userElements.filter(e => e.isLocked).length;
const hiddenCount = userElements.filter(e => e.isHidden).length;
return (
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider block">Acciones en Lote</span>
<div className="flex flex-wrap gap-1">
{/* Lock / Unlock All */}
<button
onClick={() => setTimelineElements(prev => prev.map(e =>
e.isBrandElement ? e : { ...e, isLocked: true }
))}
title="Bloquear todos los elementos"
className="flex items-center gap-1 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[8px] text-neutral-400 hover:text-amber-300 hover:border-amber-500/30 transition-colors"
>
<Lock size={9} /> Todo
</button>
<button
onClick={() => setTimelineElements(prev => prev.map(e =>
e.isBrandElement ? e : { ...e, isLocked: false }
))}
title="Desbloquear todos los elementos"
className="flex items-center gap-1 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[8px] text-neutral-400 hover:text-emerald-300 hover:border-emerald-500/30 transition-colors"
>
<Unlock size={9} /> Todo
</button>
{/* Show / Hide All */}
<button
onClick={() => setTimelineElements(prev => prev.map(e =>
e.isBrandElement ? e : { ...e, isHidden: false }
))}
title="Mostrar todos los elementos"
className="flex items-center gap-1 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[8px] text-neutral-400 hover:text-sky-300 hover:border-sky-500/30 transition-colors"
>
<Eye size={9} /> Mostrar
</button>
<button
onClick={() => setTimelineElements(prev => prev.map(e =>
e.isBrandElement ? e : { ...e, isHidden: true }
))}
title="Ocultar todos los elementos"
className="flex items-center gap-1 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[8px] text-neutral-400 hover:text-neutral-600 hover:border-neutral-600/30 transition-colors"
>
<EyeOff size={9} /> Ocultar
</button>
{/* Delete all unlocked */}
<button
onClick={() => {
if (!confirm('¿Eliminar todos los elementos desbloqueados?')) return;
setTimelineElements(prev => prev.filter(e => e.isBrandElement || e.isLocked));
setSelectedElementId(null);
}}
title="Eliminar todos los elementos desbloqueados"
className="flex items-center gap-1 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[8px] text-neutral-400 hover:text-red-300 hover:border-red-500/30 transition-colors"
>
<Trash2 size={9} /> Limpiar
</button>
{/* Duplicate all */}
<button
onClick={() => {
setTimelineElements(prev => {
const userEls = prev.filter(e => !e.isBrandElement && !e.isLocked);
const copies = userEls.map(e => ({
...e,
id: 'el-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
x: (e.x ?? 50) + 3,
y: (e.y ?? 50) + 3,
isBrandElement: false,
}));
return [...prev, ...copies];
});
}}
title="Duplicar todos los elementos desbloqueados"
className="flex items-center gap-1 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[8px] text-neutral-400 hover:text-violet-300 hover:border-violet-500/30 transition-colors"
>
<Copy size={9} /> Duplicar
</button>
</div>
{/* Summary */}
<div className="text-[7px] text-neutral-600 flex gap-3">
<span>{userElements.length} elementos</span>
<span>{lockedCount} bloqueados</span>
<span>{hiddenCount} ocultos</span>
</div>
</div>
);
};
+80
View File
@@ -0,0 +1,80 @@
import React from 'react';
interface CanvasGridOverlayProps {
showGrid: boolean;
showSafeZone: boolean;
width: number;
height: number;
}
/**
* CanvasGridOverlay — Renders SVG grid lines and safe zone guides on the canvas.
* Grid: 12-column grid with horizontal thirds.
* Safe Zone: 10% inset rectangle (title-safe area for broadcast).
*/
export const CanvasGridOverlay: React.FC<CanvasGridOverlayProps> = ({
showGrid,
showSafeZone,
width,
height,
}) => {
if (!showGrid && !showSafeZone) return null;
return (
<svg
className="absolute inset-0 pointer-events-none z-30"
width="100%"
height="100%"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
>
{/* Grid lines */}
{showGrid && (
<g>
{/* Vertical thirds */}
<line x1={width / 3} y1={0} x2={width / 3} y2={height} stroke="rgba(139,92,246,0.25)" strokeWidth="1" strokeDasharray="4 4" />
<line x1={(width * 2) / 3} y1={0} x2={(width * 2) / 3} y2={height} stroke="rgba(139,92,246,0.25)" strokeWidth="1" strokeDasharray="4 4" />
{/* Horizontal thirds */}
<line x1={0} y1={height / 3} x2={width} y2={height / 3} stroke="rgba(139,92,246,0.25)" strokeWidth="1" strokeDasharray="4 4" />
<line x1={0} y1={(height * 2) / 3} x2={width} y2={(height * 2) / 3} stroke="rgba(139,92,246,0.25)" strokeWidth="1" strokeDasharray="4 4" />
{/* Center crosshair */}
<line x1={width / 2} y1={0} x2={width / 2} y2={height} stroke="rgba(139,92,246,0.15)" strokeWidth="0.5" />
<line x1={0} y1={height / 2} x2={width} y2={height / 2} stroke="rgba(139,92,246,0.15)" strokeWidth="0.5" />
{/* Center dot */}
<circle cx={width / 2} cy={height / 2} r={3} fill="rgba(139,92,246,0.4)" />
</g>
)}
{/* Safe Zone (10% inset — broadcast title-safe area) */}
{showSafeZone && (
<g>
{/* Action-safe (5%) */}
<rect
x={width * 0.05}
y={height * 0.05}
width={width * 0.9}
height={height * 0.9}
fill="none"
stroke="rgba(251,191,36,0.3)"
strokeWidth="1"
strokeDasharray="6 3"
/>
{/* Title-safe (10%) */}
<rect
x={width * 0.1}
y={height * 0.1}
width={width * 0.8}
height={height * 0.8}
fill="none"
stroke="rgba(239,68,68,0.3)"
strokeWidth="1"
strokeDasharray="4 4"
/>
{/* Labels */}
<text x={width * 0.05 + 4} y={height * 0.05 + 10} fill="rgba(251,191,36,0.5)" fontSize="8" fontFamily="monospace">ACTION SAFE 5%</text>
<text x={width * 0.1 + 4} y={height * 0.1 + 10} fill="rgba(239,68,68,0.5)" fontSize="8" fontFamily="monospace">TITLE SAFE 10%</text>
</g>
)}
</svg>
);
};
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
/**
* CanvasWorkspace — Figma-like pasteboard component.
*
* Renders a workspace with:
* - An outer pasteboard area (optional checkerboard/grid background)
* - An inner canvas frame with overflow:hidden (clips content)
* - An extended overlay layer for off-canvas element interaction
*
* The overlay extends 60% beyond the canvas in each direction, with an inner
* reference frame that maps 0-100% to the actual canvas area.
*/
interface CanvasWorkspaceProps {
/** Aspect ratio CSS string for the inner canvas (e.g. '9/16', '16/9', '1/1') */
aspectRatio: string;
/** Whether editing mode is active (shows pasteboard grid) */
isEditing?: boolean;
/** CSS classes for the outer workspace wrapper */
className?: string;
/** CSS classes for the inner canvas frame */
canvasClassName?: string;
/** Content rendered INSIDE the canvas — gets overflow:hidden */
children: React.ReactNode;
/** Content rendered in the OVERLAY layer — can extend beyond canvas */
overlay?: React.ReactNode;
/** Ref for the overlay's inner reference frame (for drag calculations) */
overlayRef?: React.RefObject<HTMLDivElement>;
/** Pointer event handlers for the extended overlay */
onOverlayPointerMove?: (e: React.PointerEvent) => void;
onOverlayPointerUp?: (e: React.PointerEvent) => void;
/** Whether pointer events on the extended overlay should be enabled */
overlayPointerEvents?: boolean;
}
export const CanvasWorkspace: React.FC<CanvasWorkspaceProps> = ({
aspectRatio,
isEditing = false,
className = 'flex-1 min-h-0',
canvasClassName = '',
children,
overlay,
overlayRef,
onOverlayPointerMove,
onOverlayPointerUp,
overlayPointerEvents = false,
}) => {
return (
<div className={`flex items-center justify-center w-full h-full relative ${className}`}>
{/* Pasteboard grid — only visible in editing mode */}
{isEditing && (
<div
className="absolute inset-0 opacity-30 pointer-events-none"
style={{
backgroundImage: 'radial-gradient(circle, rgba(139,92,246,0.15) 1px, transparent 1px)',
backgroundSize: '20px 20px',
}}
/>
)}
{/* Inner canvas container */}
<div
className="relative"
style={{ height: '100%', maxHeight: '100%', aspectRatio }}
>
{/* Canvas frame — clips content */}
<div
className={`overflow-hidden relative w-full h-full ${canvasClassName}`}
>
{children}
</div>
{/* Extended overlay for off-canvas interaction */}
{overlay && (
<div
className="absolute z-10"
style={{
top: '-60%', left: '-60%',
width: '220%', height: '220%',
pointerEvents: overlayPointerEvents ? 'auto' : 'none',
}}
onPointerMove={onOverlayPointerMove}
onPointerUp={onOverlayPointerUp}
onPointerCancel={onOverlayPointerUp}
>
{/* Inner reference frame — maps 0-100% to the actual canvas */}
<div
ref={overlayRef}
className="absolute"
style={{
top: 'calc(60% / 2.2)',
left: 'calc(60% / 2.2)',
width: 'calc(100% / 2.2)',
height: 'calc(100% / 2.2)',
}}
>
{overlay}
</div>
</div>
)}
</div>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React, { useState } from 'react';
import { ZoomIn, ZoomOut, Maximize, Undo2, Redo2 } from 'lucide-react';
interface CanvasZoomControlsProps {
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomReset: () => void;
onFitToScreen: () => void;
onUndo?: () => void;
onRedo?: () => void;
onSetZoom?: (zoom: number) => void;
}
const ZOOM_PRESETS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3];
/**
* CanvasZoomControls — Floating zoom + undo/redo controls for the canvas workspace.
* Renders at bottom-right of the workspace with zoom percentage, undo/redo, fit, and preset buttons.
*/
export const CanvasZoomControls: React.FC<CanvasZoomControlsProps> = ({
zoom,
onZoomIn,
onZoomOut,
onZoomReset,
onFitToScreen,
onUndo,
onRedo,
onSetZoom,
}) => {
const [showPresets, setShowPresets] = useState(false);
return (
<div className="absolute bottom-3 right-3 z-20 flex items-center gap-1 bg-neutral-950/80 backdrop-blur-sm border border-neutral-800/60 rounded-lg px-1.5 py-1 shadow-xl">
{/* Undo/Redo */}
{onUndo && (
<button
onClick={onUndo}
title="Deshacer (⌘Z)"
className="p-1 rounded hover:bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
>
<Undo2 size={14} />
</button>
)}
{onRedo && (
<button
onClick={onRedo}
title="Rehacer (⌘⇧Z)"
className="p-1 rounded hover:bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
>
<Redo2 size={14} />
</button>
)}
{(onUndo || onRedo) && <div className="w-px h-4 bg-neutral-800 mx-0.5" />}
{/* Zoom controls */}
<button
onClick={onZoomOut}
title="Reducir zoom (⌘-)"
className="p-1 rounded hover:bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
>
<ZoomOut size={14} />
</button>
<div className="relative">
<button
onClick={() => onSetZoom ? setShowPresets(!showPresets) : onZoomReset()}
title="Click para presets de zoom"
className="px-2 py-0.5 rounded hover:bg-neutral-800 text-[10px] font-mono text-neutral-300 hover:text-white transition-colors min-w-[40px] text-center"
onDoubleClick={onZoomReset}
>
{Math.round(zoom * 100)}%
</button>
{/* Zoom presets dropdown */}
{showPresets && onSetZoom && (
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[80px]">
{ZOOM_PRESETS.map(z => (
<button
key={z}
onClick={() => { onSetZoom(z); setShowPresets(false); }}
title={`${Math.round(z * 100)}%`}
className={`block w-full px-3 py-1 text-[9px] font-mono text-left transition-colors ${
Math.abs(zoom - z) < 0.01
? 'bg-violet-500/20 text-violet-300'
: 'text-neutral-400 hover:bg-neutral-800 hover:text-white'
}`}
>
{Math.round(z * 100)}%
</button>
))}
</div>
)}
</div>
<button
onClick={onZoomIn}
title="Aumentar zoom (⌘+)"
className="p-1 rounded hover:bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
>
<ZoomIn size={14} />
</button>
<div className="w-px h-4 bg-neutral-800 mx-0.5" />
<button
onClick={onFitToScreen}
title="Ajustar al canvas"
className="p-1 rounded hover:bg-neutral-800 text-neutral-400 hover:text-white transition-colors"
>
<Maximize size={14} />
</button>
</div>
);
};
+77
View File
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { ChevronDown } from 'lucide-react';
interface CollapsibleSectionProps {
/** Section title */
title: string;
/** Optional icon before title */
icon?: React.ReactNode;
/** Whether the section starts open */
defaultOpen?: boolean;
/** Children rendered inside the collapsible body */
children: React.ReactNode;
/** Badge text shown to the right, e.g. "3 activos" */
badge?: string | number;
/** Extra className for the outer wrapper */
className?: string;
}
/**
* CollapsibleSection — Reusable collapsible panel section.
*
* Used across the app to separate "basic" (always visible) controls from
* "advanced" (collapsible) ones, reducing visual clutter.
*
* Usage:
* ```tsx
* <CollapsibleSection title="Tipografía Avanzada" badge={activeCount}>
* <SliderRow label="Altura de línea" ... />
* <SliderRow label="Espaciado" ... />
* </CollapsibleSection>
* ```
*/
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
icon,
defaultOpen = false,
children,
badge,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className={`border-t border-neutral-800/50 ${className}`}>
<button
onClick={() => setIsOpen(!isOpen)}
title={isOpen ? `Cerrar ${title}` : `Abrir ${title}`}
className="w-full flex items-center justify-between py-2.5 px-1 group transition-colors hover:bg-neutral-800/20 rounded-md"
>
<div className="flex items-center gap-1.5">
<ChevronDown
size={12}
className={`text-neutral-500 transition-transform duration-200 ${isOpen ? '' : '-rotate-90'}`}
/>
{icon && <span className="text-neutral-400">{icon}</span>}
<span className="text-[10px] font-semibold text-neutral-400 uppercase tracking-wider group-hover:text-neutral-200 transition-colors">
{title}
</span>
</div>
{badge != null && (badge !== 0 && badge !== '') && (
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-violet-500/15 text-violet-300 border border-violet-500/20">
{typeof badge === 'number' ? `${badge} activo${badge !== 1 ? 's' : ''}` : badge}
</span>
)}
</button>
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
isOpen ? 'max-h-[2000px] opacity-100 pb-2' : 'max-h-0 opacity-0'
}`}
>
<div className="space-y-2 px-0.5">
{children}
</div>
</div>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React, { useState, useEffect } from 'react';
import { Palette, Copy, Check } from 'lucide-react';
interface ColorPaletteExtractorProps {
imageUrl: string;
onColorSelect?: (color: string) => void;
}
/**
* ColorPaletteExtractor — Extracts dominant colors from an image.
* Uses canvas sampling to pull the 6 most prominent colors.
*/
export const ColorPaletteExtractor: React.FC<ColorPaletteExtractorProps> = ({
imageUrl,
onColorSelect,
}) => {
const [colors, setColors] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
const extractColors = () => {
setLoading(true);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Downscale for performance
const size = 50;
canvas.width = size;
canvas.height = size;
ctx.drawImage(img, 0, 0, size, size);
const data = ctx.getImageData(0, 0, size, size).data;
// Simple color bucketing
const colorMap = new Map<string, number>();
for (let i = 0; i < data.length; i += 4 * 5) { // Sample every 5th pixel
const r = Math.round(data[i] / 32) * 32;
const g = Math.round(data[i + 1] / 32) * 32;
const b = Math.round(data[i + 2] / 32) * 32;
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
colorMap.set(hex, (colorMap.get(hex) || 0) + 1);
}
// Sort by frequency, take top 6
const sorted = Array.from(colorMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 6)
.map(([hex]) => hex);
setColors(sorted);
} catch (err) {
console.warn('Color extraction failed:', err);
} finally {
setLoading(false);
}
};
img.onerror = () => setLoading(false);
img.src = imageUrl;
};
const copyColor = (color: string, idx: number) => {
navigator.clipboard?.writeText(color);
setCopiedIdx(idx);
setTimeout(() => setCopiedIdx(null), 1500);
onColorSelect?.(color);
};
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[9px] text-neutral-500 flex items-center gap-1">
<Palette size={10} />
Paleta de Colores
</span>
<button
onClick={extractColors}
title="Extraer colores de la imagen"
disabled={loading}
className="text-[8px] px-1.5 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white transition-colors border border-neutral-700 disabled:opacity-50"
>
{loading ? '...' : 'Extraer'}
</button>
</div>
{colors.length > 0 && (
<div className="flex gap-1">
{colors.map((color, i) => (
<button
key={`${color}-${i}`}
onClick={() => copyColor(color, i)}
title={`${color} — click para copiar`}
className="relative w-7 h-7 rounded-md border border-neutral-700 hover:border-white transition-all hover:scale-110 group"
style={{ backgroundColor: color }}
>
{copiedIdx === i && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-md">
<Check size={10} className="text-white" />
</div>
)}
</button>
))}
</div>
)}
</div>
);
};
+134
View File
@@ -0,0 +1,134 @@
import React, { useState, useMemo } from 'react';
import { Search, X, Type, Image, Video, Music, Square, Palette } from 'lucide-react';
import { TimelineElement } from '../../types';
interface ElementSearchProps {
timelineElements: TimelineElement[];
onSelectElement: (id: string) => void;
selectedElementId: string | null;
}
const TYPE_ICONS: Record<string, React.ReactNode> = {
text: <Type size={10} className="text-violet-400" />,
image: <Image size={10} className="text-sky-400" />,
video: <Video size={10} className="text-rose-400" />,
audio: <Music size={10} className="text-amber-400" />,
shape: <Square size={10} className="text-emerald-400" />,
sticker: <span className="text-[10px]">🎨</span>,
color: <Palette size={10} className="text-neutral-400" />,
};
/**
* ElementSearch — Quick search and filter for timeline elements.
* Allows filtering by name, content, or type.
*/
export const ElementSearch: React.FC<ElementSearchProps> = ({
timelineElements,
onSelectElement,
selectedElementId,
}) => {
const [query, setQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const filtered = useMemo(() => {
return timelineElements
.filter(el => !el.isBrandElement)
.filter(el => {
if (typeFilter && el.type !== typeFilter) return false;
if (!query) return true;
const q = query.toLowerCase();
const name = (el.elementName ?? '').toLowerCase();
const content = (el.content ?? '').toLowerCase();
const type = el.type.toLowerCase();
return name.includes(q) || content.includes(q) || type.includes(q);
});
}, [timelineElements, query, typeFilter]);
const types = useMemo(() => {
const t = new Set(timelineElements.filter(e => !e.isBrandElement).map(e => e.type));
return Array.from(t);
}, [timelineElements]);
return (
<div className="space-y-1.5">
{/* Search input */}
<div className="relative">
<Search size={10} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-600" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar elementos..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-md text-[9px] text-neutral-300 pl-6 pr-6 py-1 outline-none focus:border-violet-500/40 placeholder-neutral-600"
title="Buscar por nombre, contenido o tipo"
/>
{query && (
<button
onClick={() => setQuery('')}
title="Limpiar búsqueda"
className="absolute right-1 top-1/2 -translate-y-1/2 text-neutral-600 hover:text-neutral-400 p-0.5"
>
<X size={8} />
</button>
)}
</div>
{/* Type filter chips */}
<div className="flex gap-0.5 flex-wrap">
<button
onClick={() => setTypeFilter(null)}
title="Mostrar todos"
className={`px-1.5 py-0.5 rounded text-[7px] transition-colors border ${
!typeFilter
? 'bg-violet-500/20 border-violet-500/40 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-600 hover:text-neutral-400'
}`}
>
Todos
</button>
{types.map(type => (
<button
key={type}
onClick={() => setTypeFilter(typeFilter === type ? null : type)}
title={`Filtrar: ${type}`}
className={`px-1.5 py-0.5 rounded text-[7px] transition-colors border flex items-center gap-0.5 ${
typeFilter === type
? 'bg-violet-500/20 border-violet-500/40 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-600 hover:text-neutral-400'
}`}
>
{TYPE_ICONS[type]}
{type}
</button>
))}
</div>
{/* Results */}
<div className="max-h-40 overflow-y-auto custom-scrollbar space-y-0.5">
{filtered.length === 0 && (
<div className="text-[8px] text-neutral-600 text-center py-2">Sin resultados</div>
)}
{filtered.map(el => (
<button
key={el.id}
onClick={() => onSelectElement(el.id)}
title={el.elementName || el.content?.slice(0, 40) || el.type}
className={`w-full flex items-center gap-1.5 px-2 py-1 rounded text-left transition-all ${
selectedElementId === el.id
? 'bg-violet-500/15 border border-violet-500/30'
: 'hover:bg-neutral-800/50 border border-transparent'
}`}
>
{TYPE_ICONS[el.type] || <span className="w-2.5" />}
<span className="text-[8px] text-neutral-300 truncate flex-1">
{el.elementName || el.content?.slice(0, 30) || el.type}
</span>
<span className="text-[7px] text-neutral-600 font-mono flex-shrink-0">
{((el.endFrame - el.startFrame) / 30).toFixed(1)}s
</span>
</button>
))}
</div>
</div>
);
};
@@ -0,0 +1,84 @@
import React from 'react';
interface ExportQualityPresetsProps {
selectedFormat: string;
selectedQuality: string;
onFormatChange: (format: string) => void;
onQualityChange: (quality: string) => void;
}
const FORMAT_OPTIONS = [
{ value: 'mp4', label: 'MP4', icon: '🎬', desc: 'Mejor compatibilidad' },
{ value: 'webm', label: 'WebM', icon: '🌐', desc: 'Web optimizado' },
{ value: 'gif', label: 'GIF', icon: '✨', desc: 'Animación ligera' },
{ value: 'png', label: 'PNG', icon: '🖼️', desc: 'Frame estático' },
{ value: 'jpeg', label: 'JPEG', icon: '📷', desc: 'Foto comprimida' },
];
const QUALITY_PRESETS = [
{ value: 'draft', label: 'Draft', desc: '480p · Rápido · Preview', color: 'text-neutral-400' },
{ value: 'standard', label: 'Standard', desc: '720p · Balanceado', color: 'text-sky-400' },
{ value: 'high', label: 'High', desc: '1080p · Alta calidad', color: 'text-violet-400' },
{ value: 'ultra', label: 'Ultra', desc: '4K · Máxima calidad', color: 'text-amber-400' },
];
/**
* ExportQualityPresets — Format and quality selector for video/image export.
* Provides quick access to common export configurations.
*/
export const ExportQualityPresets: React.FC<ExportQualityPresetsProps> = ({
selectedFormat,
selectedQuality,
onFormatChange,
onQualityChange,
}) => {
return (
<div className="space-y-3">
{/* Format selector */}
<div>
<span className="text-[9px] text-neutral-500 uppercase tracking-wider block mb-1.5">Formato</span>
<div className="grid grid-cols-5 gap-1">
{FORMAT_OPTIONS.map(fmt => (
<button
key={fmt.value}
onClick={() => onFormatChange(fmt.value)}
title={fmt.desc}
className={`py-1.5 rounded-lg text-[8px] font-medium transition-all border text-center ${
selectedFormat === fmt.value
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
<span className="text-xs block">{fmt.icon}</span>
{fmt.label}
</button>
))}
</div>
</div>
{/* Quality selector */}
<div>
<span className="text-[9px] text-neutral-500 uppercase tracking-wider block mb-1.5">Calidad</span>
<div className="grid grid-cols-2 gap-1">
{QUALITY_PRESETS.map(q => (
<button
key={q.value}
onClick={() => onQualityChange(q.value)}
title={q.desc}
className={`py-1.5 px-2 rounded-lg text-left transition-all border ${
selectedQuality === q.value
? 'bg-violet-500/15 border-violet-500/50'
: 'bg-neutral-900 border-neutral-800 hover:border-neutral-700'
}`}
>
<span className={`text-[9px] font-semibold block ${selectedQuality === q.value ? 'text-white' : q.color}`}>
{q.label}
</span>
<span className="text-[7px] text-neutral-600 block">{q.desc}</span>
</button>
))}
</div>
</div>
</div>
);
};
+371
View File
@@ -0,0 +1,371 @@
import React, { useState } from 'react';
import { Move, AlignLeft, AlignCenter, AlignRight, ChevronDown, ChevronRight, Palette } from 'lucide-react';
import { AlignmentTools } from './AlignmentTools';
import { FontPicker } from './FontPicker';
import { DesignMD } from '../../types';
/* ─── Types ─── */
export interface FieldPosition {
x: number;
y: number;
w: number;
h: number;
}
export interface FieldTextStyle {
fontSize?: number;
fontWeight?: number;
fontFamily?: string;
color?: string;
textAlign?: 'left' | 'center' | 'right';
opacity?: number;
useBrandStyle?: boolean;
textRole?: 'title' | 'subtitle' | 'paragraph';
}
/** Resolve brand typography values for a given role */
export function resolveBrandRole(designMD: DesignMD, role: 'title' | 'subtitle' | 'paragraph') {
switch (role) {
case 'title': return {
fontFamily: designMD.titleFont || designMD.baseFont,
fontSize: designMD.titleSize || 48,
fontWeight: 700,
color: designMD.titleColor || designMD.textColor,
};
case 'subtitle': return {
fontFamily: designMD.subtitleFont || designMD.baseFont,
fontSize: designMD.subtitleSize || 32,
fontWeight: 600,
color: designMD.subtitleColor || designMD.textColor,
};
case 'paragraph': return {
fontFamily: designMD.paragraphFont || designMD.baseFont,
fontSize: designMD.paragraphSize || 18,
fontWeight: 400,
color: designMD.paragraphColor || designMD.textColor,
};
}
}
interface FieldInspectorProps {
/** Current position (0-100 %) */
position: FieldPosition;
onPositionChange: (pos: Partial<FieldPosition>) => void;
/** Text style (only rendered when provided) */
textStyle?: FieldTextStyle;
onTextStyleChange?: (style: Partial<FieldTextStyle>) => void;
/** Field metadata */
fieldType: 'text' | 'media' | 'logo' | 'brand-variable';
fieldLabel: string;
/** Brand context for FontPicker and color palette */
brandFont?: string;
brandColors?: string[];
/** Resolved brand design for typography roles */
resolvedDesignMD?: DesignMD;
}
/* ─── Collapsible Section ─── */
const Section: React.FC<{ title: string; icon?: React.ReactNode; defaultOpen?: boolean; children: React.ReactNode }> = ({
title, icon, defaultOpen = true, children,
}) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="space-y-1.5">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-1 text-[8px] text-neutral-400 font-semibold uppercase tracking-wider hover:text-neutral-200 transition-colors"
>
{icon}
{title}
{open ? <ChevronDown size={8} className="ml-auto" /> : <ChevronRight size={8} className="ml-auto" />}
</button>
{open && children}
</div>
);
};
/**
* FieldInspector — Shared property inspector for positioned canvas fields.
*
* Used by:
* - Template Builder (SceneConfigurator) for ExpressField
* - Potentially Studio (ElementPropertiesPanel) in the future
*
* Reuses existing shared components: AlignmentTools, FontPicker.
*/
export const FieldInspector: React.FC<FieldInspectorProps> = ({
position,
onPositionChange,
textStyle,
onTextStyleChange,
fieldType,
fieldLabel,
brandFont,
brandColors = [],
resolvedDesignMD,
}) => {
const useBrand = textStyle?.useBrandStyle !== false;
const currentRole = textStyle?.textRole || 'paragraph';
return (
<div className="bg-violet-500/5 border border-violet-500/20 rounded-lg p-2.5 space-y-3">
{/* Header */}
<div className="flex items-center gap-1.5 text-[8px] text-violet-300 font-semibold uppercase tracking-wider">
<Move size={8} />
{fieldLabel}
<span className="ml-auto text-[7px] text-neutral-500 normal-case tracking-normal">
{fieldType}
</span>
</div>
{/* ── Position & Size Grid ── */}
<Section title="Posición y Tamaño" icon={<Move size={8} />}>
<div className="grid grid-cols-4 gap-1">
{([
{ key: 'x' as const, label: 'X' },
{ key: 'y' as const, label: 'Y' },
{ key: 'w' as const, label: 'W' },
{ key: 'h' as const, label: 'H' },
]).map(p => (
<div key={p.key} className="space-y-0.5">
<label className="text-[7px] text-neutral-500 font-mono">{p.label}%</label>
<input
type="number"
min={0}
max={100}
step={1}
value={Math.round(position[p.key])}
onChange={(e) => {
const val = Math.max(0, Math.min(100, parseInt(e.target.value) || 0));
onPositionChange({ [p.key]: val });
}}
title={`${p.label} (${Math.round(position[p.key])}%)`}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-1.5 py-1 text-[10px] text-white text-center font-mono focus:border-violet-500/50 focus:outline-none"
/>
</div>
))}
</div>
{/* Alignment Tools */}
<div className="pt-1.5">
<AlignmentTools
onAlign={(updates) => onPositionChange(updates as Partial<FieldPosition>)}
/>
</div>
</Section>
{/* ── Text Styling (only for text fields) ── */}
{textStyle && onTextStyleChange && (
<Section title="Estilo de Texto" defaultOpen={true}>
<div className="space-y-2">
{/* ── Typographic Role Pills ── */}
<div className="space-y-0.5">
<label className="text-[7px] text-neutral-500 font-mono">Rol tipográfico</label>
<div className="flex gap-0.5">
{(['title', 'subtitle', 'paragraph'] as const).map(role => {
const labels = { title: 'Título', subtitle: 'Subtítulo', paragraph: 'Párrafo' };
const isActive = currentRole === role;
return (
<button
key={role}
onClick={() => {
const updates: Partial<FieldTextStyle> = { textRole: role };
// Auto-apply brand values when in brand mode
if (useBrand && resolvedDesignMD) {
const brandVals = resolveBrandRole(resolvedDesignMD, role);
Object.assign(updates, brandVals);
}
onTextStyleChange(updates);
}}
title={labels[role]}
className={`flex-1 py-1.5 rounded-md text-[9px] font-semibold transition-all border ${
isActive
? 'bg-violet-600/20 border-violet-500/50 text-violet-300 shadow-sm'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600'
}`}
>
{labels[role]}
</button>
);
})}
</div>
</div>
{/* ── Text Align (always visible) ── */}
<div className="space-y-0.5">
<label className="text-[7px] text-neutral-500 font-mono">Alineación</label>
<div className="flex gap-0.5">
{([
{ value: 'left' as const, icon: <AlignLeft size={12} /> },
{ value: 'center' as const, icon: <AlignCenter size={12} /> },
{ value: 'right' as const, icon: <AlignRight size={12} /> },
]).map(a => (
<button
key={a.value}
onClick={() => onTextStyleChange({ textAlign: a.value })}
title={`Alinear ${a.value}`}
className={`flex-1 py-1 rounded-md border transition-all flex items-center justify-center ${
(textStyle.textAlign || 'center') === a.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{a.icon}
</button>
))}
</div>
</div>
{/* ── Brand toggle ── */}
<div className="flex items-center justify-between pt-1">
<label className="text-[8px] text-neutral-400 flex items-center gap-1 cursor-pointer">
<Palette size={10} className={useBrand ? 'text-violet-400' : 'text-neutral-500'} />
Usar marca
</label>
<button
onClick={() => {
const nextUseBrand = !useBrand;
const updates: Partial<FieldTextStyle> = { useBrandStyle: nextUseBrand };
// When switching to brand mode, re-apply brand values
if (nextUseBrand && resolvedDesignMD) {
const brandVals = resolveBrandRole(resolvedDesignMD, currentRole);
Object.assign(updates, brandVals);
}
onTextStyleChange(updates);
}}
title={useBrand ? 'Desactivar estilos de marca' : 'Activar estilos de marca'}
className={`w-8 h-4 rounded-full relative transition-all ${
useBrand ? 'bg-violet-600' : 'bg-neutral-700'
}`}
>
<span className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-all ${
useBrand ? 'left-[18px]' : 'left-0.5'
}`} />
</button>
</div>
{/* ── Brand mode preview ── */}
{useBrand && resolvedDesignMD && (
<div className="bg-neutral-800/50 rounded-lg p-2 border border-neutral-700/50">
{(() => {
const vals = resolveBrandRole(resolvedDesignMD, currentRole);
const fontName = (vals.fontFamily || 'Inter').split(',')[0].replace(/"/g, '');
return (
<div className="flex items-center gap-2 text-[8px] text-neutral-400">
<span className="font-mono">{fontName}</span>
<span>·</span>
<span className="font-mono">{vals.fontSize}px</span>
<span>·</span>
<span className="font-mono">{vals.fontWeight}</span>
<span>·</span>
<div className="w-3 h-3 rounded-full border border-neutral-600" style={{ backgroundColor: vals.color }} title={vals.color} />
</div>
);
})()}
</div>
)}
{/* ── Advanced controls (only when brand is OFF) ── */}
{!useBrand && (
<div className="space-y-2 pt-1 border-t border-neutral-800/50">
{/* Font Size + Weight row */}
<div className="grid grid-cols-2 gap-1">
<div className="space-y-0.5">
<label className="text-[7px] text-neutral-500 font-mono">Size</label>
<input
type="number"
min={8}
max={120}
value={textStyle.fontSize || 24}
onChange={(e) => onTextStyleChange({ fontSize: parseInt(e.target.value) || 24 })}
title={`Tamaño: ${textStyle.fontSize || 24}px`}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-1.5 py-1 text-[10px] text-white text-center font-mono focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="space-y-0.5">
<label className="text-[7px] text-neutral-500 font-mono">Weight</label>
<select
value={textStyle.fontWeight || 400}
onChange={(e) => onTextStyleChange({ fontWeight: parseInt(e.target.value) })}
title="Peso de fuente"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-1 py-1 text-[10px] text-white font-mono focus:border-violet-500/50 focus:outline-none"
>
<option value={300}>Light</option>
<option value={400}>Normal</option>
<option value={600}>Semi</option>
<option value={700}>Bold</option>
<option value={900}>Black</option>
</select>
</div>
</div>
{/* Font Picker */}
<div className="space-y-0.5">
<label className="text-[7px] text-neutral-500 font-mono">Fuente</label>
<FontPicker
value={textStyle.fontFamily || 'Inter'}
onChange={(font) => onTextStyleChange({ fontFamily: font })}
brandFont={brandFont}
/>
</div>
{/* Color */}
<div className="space-y-0.5">
<label className="text-[7px] text-neutral-500 font-mono">Color</label>
<div className="flex items-center gap-1">
<input
type="color"
value={textStyle.color || '#ffffff'}
onChange={(e) => onTextStyleChange({ color: e.target.value })}
title="Color del texto"
className="w-7 h-7 rounded cursor-pointer bg-transparent border border-neutral-700 p-0"
/>
<span className="text-[8px] text-neutral-500 font-mono">{textStyle.color || '#ffffff'}</span>
{/* Brand color quick-picks */}
{brandColors.length > 0 && (
<div className="flex gap-0.5 ml-auto">
{brandColors.map((c, i) => (
<button
key={`${c}-${i}`}
onClick={() => onTextStyleChange({ color: c })}
title={c}
className="w-4 h-4 rounded-full border border-neutral-700 hover:border-neutral-500 transition-colors hover:scale-110"
style={{ backgroundColor: c }}
/>
))}
</div>
)}
</div>
</div>
{/* Opacity */}
<div className="space-y-0.5">
<div className="flex items-center justify-between">
<label className="text-[7px] text-neutral-500 font-mono">Opacidad</label>
<span className="text-[7px] text-neutral-600 font-mono">{Math.round((textStyle.opacity ?? 1) * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={Math.round((textStyle.opacity ?? 1) * 100)}
onChange={(e) => onTextStyleChange({ opacity: Number(e.target.value) / 100 })}
title="Opacidad del texto"
className="w-full h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
/>
</div>
</div>
)}
</div>
</Section>
)}
</div>
);
};
+214
View File
@@ -0,0 +1,214 @@
import React, { useRef, useState, useCallback } from 'react';
import { UploadCloud } from 'lucide-react';
interface FileDropZoneProps {
/** MIME accept string, e.g. "image/*", "video/*", "audio/*" */
accept: string;
/** Allow selecting multiple files */
multiple?: boolean;
/** Callback with the selected/dropped files */
onFiles: (files: File[]) => void;
/** Primary label text */
label?: string;
/** Secondary label text */
sublabel?: string;
/** Custom icon — defaults to UploadCloud */
icon?: React.ReactNode;
/** Compact mode renders as a small inline button */
compact?: boolean;
/** Disable interactions */
disabled?: boolean;
/** Additional className for the container */
className?: string;
}
/**
* Reusable file upload component with drag-and-drop support.
*
* Two modes:
* - **Default**: Large drop zone with icon, label, and sublabel
* - **Compact**: Small inline button that still supports drag-and-drop
*
* Both modes support click-to-browse and drag-and-drop.
*/
export const FileDropZone: React.FC<FileDropZoneProps> = ({
accept,
multiple = false,
onFiles,
label,
sublabel,
icon,
compact = false,
disabled = false,
className = '',
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const dragCounter = useRef(0);
const handleFiles = useCallback((fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
const files = Array.from(fileList);
// Filter by accept types if possible
const filtered = filterByAccept(files, accept);
if (filtered.length > 0) {
onFiles(multiple ? filtered : [filtered[0]]);
}
}, [accept, multiple, onFiles]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files);
e.target.value = '';
}, [handleFiles]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (dragCounter.current === 1) {
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setIsDragOver(false);
if (!disabled) {
handleFiles(e.dataTransfer.files);
}
}, [disabled, handleFiles]);
const handleClick = useCallback(() => {
if (!disabled) {
inputRef.current?.click();
}
}, [disabled]);
const defaultLabel = label || (multiple ? 'Subir archivos' : 'Subir archivo');
const defaultSublabel = sublabel || 'o arrastra aquí';
const iconElement = icon || <UploadCloud size={compact ? 14 : 24} />;
// ─── Compact mode ───
if (compact) {
return (
<div
className={`relative ${className}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<button
type="button"
onClick={handleClick}
disabled={disabled}
title={defaultLabel}
className={`w-full bg-neutral-950 border rounded-lg py-1.5 text-center text-[10px] font-medium transition-all flex items-center justify-center gap-1.5 ${
isDragOver
? 'border-violet-500 bg-violet-500/10 text-violet-300 ring-2 ring-violet-500/20'
: 'border-neutral-700 hover:border-violet-500 text-neutral-300 hover:text-white'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{iconElement}
{isDragOver ? 'Suelta aquí' : defaultLabel}
</button>
<input
type="file"
ref={inputRef}
className="hidden"
accept={accept}
multiple={multiple}
onChange={handleInputChange}
/>
</div>
);
}
// ─── Default mode (large drop zone) ───
return (
<div
className={`relative ${className}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<button
type="button"
onClick={handleClick}
disabled={disabled}
title={defaultLabel}
className={`w-full rounded-xl p-4 flex flex-col items-center justify-center gap-2 transition-all border-2 border-dashed ${
isDragOver
? 'border-violet-500 bg-violet-500/10 text-violet-300 ring-2 ring-violet-500/20 scale-[1.01]'
: 'border-neutral-700 bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white hover:border-neutral-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={`transition-transform ${isDragOver ? 'scale-110 -translate-y-1' : ''}`}>
{iconElement}
</div>
<span className="text-sm font-medium">
{isDragOver ? 'Suelta el archivo aquí' : defaultLabel}
</span>
{!isDragOver && (
<span className="text-xs text-neutral-500">{defaultSublabel}</span>
)}
</button>
<input
type="file"
ref={inputRef}
className="hidden"
accept={accept}
multiple={multiple}
onChange={handleInputChange}
/>
</div>
);
};
// ─── Helpers ───
/** Filter files by a MIME accept string (basic matching for common patterns) */
function filterByAccept(files: File[], accept: string): File[] {
if (!accept || accept === '*') return files;
const patterns = accept.split(',').map(p => p.trim().toLowerCase());
return files.filter(file => {
const type = file.type.toLowerCase();
const ext = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
return patterns.some(pattern => {
// Wildcard: "image/*", "video/*", "audio/*"
if (pattern.endsWith('/*')) {
const prefix = pattern.slice(0, -2);
return type.startsWith(prefix);
}
// Exact MIME: "image/png"
if (pattern.includes('/')) {
return type === pattern;
}
// Extension: ".mp3", ".png"
if (pattern.startsWith('.')) {
return ext === pattern;
}
return false;
});
});
}
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { TimelineElement } from '../../types';
interface FilterPresetsProps {
element: TimelineElement;
onUpdate: (updates: Partial<TimelineElement>) => void;
}
const FILTER_PRESETS = [
{ name: 'Original', brightness: 100, contrast: 100, saturation: 100 },
{ name: 'Vivid', brightness: 105, contrast: 115, saturation: 140 },
{ name: 'Moody', brightness: 90, contrast: 120, saturation: 70 },
{ name: 'Warm', brightness: 105, contrast: 105, saturation: 120 },
{ name: 'Cool', brightness: 100, contrast: 110, saturation: 80 },
{ name: 'Film', brightness: 95, contrast: 130, saturation: 85 },
{ name: 'B&W', brightness: 100, contrast: 110, saturation: 0 },
{ name: 'Retro', brightness: 110, contrast: 90, saturation: 130 },
{ name: 'Dramatic', brightness: 85, contrast: 150, saturation: 90 },
{ name: 'Soft', brightness: 110, contrast: 85, saturation: 90 },
{ name: 'Muted', brightness: 100, contrast: 95, saturation: 50 },
{ name: 'Neon', brightness: 110, contrast: 130, saturation: 180 },
];
/**
* FilterPresets — Quick-apply color filter presets to images/videos.
* Each preset adjusts brightness, contrast, and saturation.
*/
export const FilterPresets: React.FC<FilterPresetsProps> = ({ element, onUpdate }) => {
const currentKey = `${element.brightness ?? 100}-${element.contrast ?? 100}-${element.saturation ?? 100}`;
return (
<div className="space-y-1.5">
<span className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Filtros Rápidos</span>
<div className="grid grid-cols-4 gap-1">
{FILTER_PRESETS.map((preset) => {
const presetKey = `${preset.brightness}-${preset.contrast}-${preset.saturation}`;
const isActive = currentKey === presetKey;
return (
<button
key={preset.name}
onClick={() => onUpdate({
brightness: preset.brightness,
contrast: preset.contrast,
saturation: preset.saturation,
})}
title={`Filtro: ${preset.name}`}
className={`py-1.5 px-1 rounded-lg text-[8px] font-medium transition-all border ${
isActive
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
{preset.name}
</button>
);
})}
</div>
</div>
);
};
+251
View File
@@ -0,0 +1,251 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Search, ChevronDown, Star, Clock, Loader2 } from 'lucide-react';
import {
FontMeta,
fetchGoogleFonts,
searchFonts,
loadGoogleFont,
getRecentFonts,
addRecentFont,
groupFontsByCategory,
isFontLoaded,
} from '../../utils/googleFontsApi';
interface FontPickerProps {
value: string;
onChange: (fontFamily: string) => void;
disabled?: boolean;
brandFont?: string;
}
const VISIBLE_LIMIT = 50;
/**
* Font picker with search, lazy loading, recent fonts, and category grouping.
* Uses IntersectionObserver to lazy-load font CSS as items scroll into view.
*/
export const FontPicker: React.FC<FontPickerProps> = ({
value,
onChange,
disabled = false,
brandFont,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [allFonts, setAllFonts] = useState<FontMeta[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [recentFonts, setRecentFonts] = useState<string[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
// ─── Load font catalog on mount ───
useEffect(() => {
fetchGoogleFonts().then(fonts => setAllFonts(fonts));
setRecentFonts(getRecentFonts());
}, []);
// ─── Filtered results ───
const filtered = useMemo(() => {
const results = searchFonts(query, allFonts);
return results.slice(0, VISIBLE_LIMIT);
}, [query, allFonts]);
const grouped = useMemo(() => {
if (query.trim()) return null; // Don't group when searching
return groupFontsByCategory(filtered);
}, [filtered, query]);
// ─── Close on outside click ───
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isOpen]);
// ─── Focus search on open ───
useEffect(() => {
if (isOpen) {
setTimeout(() => searchRef.current?.focus(), 50);
} else {
setQuery('');
}
}, [isOpen]);
// ─── IntersectionObserver to lazy-load fonts ───
const itemRef = useCallback((node: HTMLDivElement | null) => {
if (!node) return;
if (!observerRef.current) {
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const fontFamily = entry.target.getAttribute('data-font');
if (fontFamily && !isFontLoaded(fontFamily)) {
loadGoogleFont(fontFamily);
}
}
});
},
{ root: null, threshold: 0.1 }
);
}
observerRef.current.observe(node);
}, []);
// Clean up observer
useEffect(() => {
return () => {
observerRef.current?.disconnect();
};
}, []);
const handleSelect = async (family: string) => {
setIsLoading(true);
try {
await loadGoogleFont(family);
addRecentFont(family);
setRecentFonts(getRecentFonts());
onChange(family);
} finally {
setIsLoading(false);
setIsOpen(false);
}
};
// Load the currently selected font on mount
useEffect(() => {
if (value && !isFontLoaded(value)) {
loadGoogleFont(value);
}
}, [value]);
const renderFontItem = (font: FontMeta | { family: string }, isBrand = false, isRecent = false) => (
<div
key={`${isBrand ? 'brand-' : isRecent ? 'recent-' : ''}${font.family}`}
ref={itemRef}
data-font={font.family}
onClick={() => handleSelect(font.family)}
className={`px-3 py-2 cursor-pointer flex items-center gap-2 transition-colors rounded-md mx-1 ${
value === font.family
? 'bg-violet-600/20 text-violet-300'
: 'text-neutral-300 hover:bg-neutral-800/80'
}`}
>
{isBrand && <Star size={10} className="text-amber-400 shrink-0" />}
{isRecent && <Clock size={10} className="text-neutral-500 shrink-0" />}
<span
className="text-xs truncate flex-1"
style={{ fontFamily: `"${font.family}", sans-serif` }}
>
{font.family}
</span>
{'category' in font && (
<span className="text-[8px] text-neutral-600 shrink-0 uppercase tracking-wider">
{font.category?.replace('-', ' ')}
</span>
)}
</div>
);
return (
<div ref={dropdownRef} className="relative">
{/* Trigger */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
title="Seleccionar Fuente"
className={`w-full flex items-center justify-between gap-2 bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-2 text-xs transition-colors ${
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:border-neutral-700 cursor-pointer'
} ${isOpen ? 'border-violet-500/50' : ''}`}
>
<span
className="truncate text-neutral-300"
style={{ fontFamily: `"${value}", sans-serif` }}
>
{isLoading ? 'Cargando...' : value || 'Seleccionar fuente'}
</span>
<ChevronDown
size={12}
className={`text-neutral-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-neutral-900 border border-neutral-700 rounded-lg shadow-2xl shadow-black/50 overflow-hidden max-h-[320px] flex flex-col">
{/* Search */}
<div className="p-2 border-b border-neutral-800 shrink-0">
<div className="relative">
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar fuente..."
className="w-full bg-neutral-800 border border-neutral-700 rounded-md pl-7 pr-3 py-1.5 text-[11px] text-white placeholder-neutral-500 focus:outline-none focus:border-violet-500"
/>
</div>
</div>
{/* Font list */}
<div className="overflow-y-auto flex-1 py-1 custom-scrollbar">
{/* Brand font */}
{brandFont && !query && (
<div className="mb-1">
<div className="px-3 py-1">
<span className="text-[9px] font-semibold text-amber-500/70 uppercase tracking-widest">Marca</span>
</div>
{renderFontItem({ family: brandFont }, true)}
</div>
)}
{/* Recent fonts */}
{recentFonts.length > 0 && !query && (
<div className="mb-1 border-b border-neutral-800/50 pb-1">
<div className="px-3 py-1">
<span className="text-[9px] font-semibold text-neutral-600 uppercase tracking-widest">Recientes</span>
</div>
{recentFonts
.filter(f => f !== brandFont)
.slice(0, 5)
.map(family => renderFontItem({ family }, false, true))}
</div>
)}
{/* Grouped or flat results */}
{query ? (
// Flat search results
filtered.length > 0 ? (
filtered.map(font => renderFontItem(font))
) : (
<div className="text-center py-4 text-neutral-500 text-xs">
Sin resultados para "{query}"
</div>
)
) : (
// Grouped by category
grouped && (Object.entries(grouped) as [string, FontMeta[]][]).map(([category, fonts]) => (
<div key={category} className="mb-1">
<div className="px-3 py-1.5 sticky top-0 bg-neutral-900/95 backdrop-blur-sm">
<span className="text-[9px] font-semibold text-neutral-500 uppercase tracking-widest">{category}</span>
</div>
{fonts.map(font => renderFontItem(font))}
</div>
))
)}
</div>
</div>
)}
</div>
);
};
+96
View File
@@ -0,0 +1,96 @@
import React, { useState, useEffect, useCallback } from 'react';
export function FullscreenToggle() {
const [isFullscreen, setIsFullscreen] = useState(false);
// Sync state with actual fullscreen changes (e.g. user presses Esc)
useEffect(() => {
const handleChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleChange);
return () => document.removeEventListener('fullscreenchange', handleChange);
}, []);
const toggleFullscreen = useCallback(async () => {
try {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
} catch (err) {
console.warn('Fullscreen toggle failed:', err);
}
}, []);
// F11 keyboard shortcut
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'F11') {
e.preventDefault();
toggleFullscreen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleFullscreen]);
return (
<button
onClick={toggleFullscreen}
title={isFullscreen ? 'Salir de pantalla completa (F11)' : 'Pantalla completa (F11)'}
className="fullscreen-toggle-btn"
style={{
position: 'fixed',
bottom: 12,
right: 12,
zIndex: 9999,
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(23,23,23,0.85)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
color: 'rgba(255,255,255,0.5)',
cursor: 'pointer',
transition: 'all 0.2s ease',
padding: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(40,40,40,0.95)';
e.currentTarget.style.color = 'rgba(255,255,255,0.9)';
e.currentTarget.style.borderColor = 'rgba(139,92,246,0.4)';
e.currentTarget.style.boxShadow = '0 0 12px rgba(139,92,246,0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(23,23,23,0.85)';
e.currentTarget.style.color = 'rgba(255,255,255,0.5)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{isFullscreen ? (
// Exit fullscreen icon (arrows pointing inward)
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="14" y1="10" x2="21" y2="3" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
) : (
// Enter fullscreen icon (arrows pointing outward)
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
)}
</button>
);
}
+99
View File
@@ -0,0 +1,99 @@
import React, { useState, useEffect, useRef } from 'react';
import { PlayerRef } from '@remotion/player';
interface PlaybackInfoProps {
playerRef: React.RefObject<PlayerRef | null>;
durationInFrames: number;
fps?: number;
elementCount?: number;
}
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
/**
* PlaybackInfo — Shows current time/frame, total duration, progress bar, and preview speed control.
* Auto-updates while playing via requestAnimationFrame.
*/
export const PlaybackInfo: React.FC<PlaybackInfoProps> = ({
playerRef,
durationInFrames,
fps = 30,
elementCount,
}) => {
const [currentFrame, setCurrentFrame] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
const rafRef = useRef<number>();
useEffect(() => {
const player = playerRef.current;
if (!player) return;
const updateFrame = () => {
try {
setCurrentFrame(player.getCurrentFrame());
setIsPlaying(player.isPlaying());
} catch {}
rafRef.current = requestAnimationFrame(updateFrame);
};
rafRef.current = requestAnimationFrame(updateFrame);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [playerRef]);
const handleSpeedCycle = () => {
const currentIdx = SPEED_OPTIONS.indexOf(speed as (typeof SPEED_OPTIONS)[number]);
const nextIdx = (currentIdx + 1) % SPEED_OPTIONS.length;
const newSpeed = SPEED_OPTIONS[nextIdx];
setSpeed(newSpeed);
window.dispatchEvent(new CustomEvent('preview-speed-change', { detail: newSpeed }));
};
const currentTime = (currentFrame / fps).toFixed(1);
const totalTime = (durationInFrames / fps).toFixed(1);
const progress = durationInFrames > 0 ? (currentFrame / durationInFrames) * 100 : 0;
return (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2 bg-neutral-950/70 backdrop-blur-sm border border-neutral-800/40 rounded-lg px-2.5 py-1 shadow-lg">
{/* Mini progress bar */}
<div className="w-12 h-1 bg-neutral-800 rounded-full overflow-hidden" title={`${progress.toFixed(0)}%`}>
<div
className="h-full bg-violet-500 transition-all duration-75 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-1">
<div className={`w-1.5 h-1.5 rounded-full ${isPlaying ? 'bg-red-500 animate-pulse' : 'bg-neutral-600'}`} />
<span className="text-[10px] font-mono text-neutral-300">
{currentTime}s
</span>
<span className="text-[10px] font-mono text-neutral-600">/</span>
<span className="text-[10px] font-mono text-neutral-500">
{totalTime}s
</span>
</div>
<span className="text-[9px] font-mono text-neutral-600">
F{currentFrame}
</span>
{/* Speed button */}
<button
onClick={handleSpeedCycle}
title="Cambiar velocidad de preview (clic para ciclar)"
className={`text-[9px] font-mono px-1.5 py-0.5 rounded transition-colors ${
speed !== 1
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
: 'text-neutral-600 hover:text-neutral-400 border border-transparent'
}`}
>
{speed}x
</button>
{elementCount !== undefined && (
<span className="text-[9px] font-mono text-neutral-600" title="Elementos en composición">
| {elementCount} el
</span>
)}
</div>
);
};
+96
View File
@@ -0,0 +1,96 @@
import React, { useMemo } from 'react';
import { BarChart3, Layers, Clock, Film, Type, Image, Video, Music } from 'lucide-react';
import { TimelineElement, TimelineLayer } from '../../types';
interface ProjectStatsProps {
timelineElements: TimelineElement[];
layers: TimelineLayer[];
durationInFrames: number;
fps: number;
}
/**
* ProjectStats — Displays compact project statistics:
* element count by type, layer count, total duration, etc.
*/
export const ProjectStats: React.FC<ProjectStatsProps> = ({
timelineElements,
layers,
durationInFrames,
fps,
}) => {
const stats = useMemo(() => {
const userElements = timelineElements.filter(e => !e.isBrandElement);
const typeCounts: Record<string, number> = {};
userElements.forEach(el => {
typeCounts[el.type] = (typeCounts[el.type] || 0) + 1;
});
const totalDuration = durationInFrames / fps;
const longestEl = userElements.reduce((max, el) =>
(el.endFrame - el.startFrame) > (max.endFrame - max.startFrame) ? el : max
, userElements[0]);
return {
total: userElements.length,
typeCounts,
layerCount: layers.length,
durationSec: totalDuration,
longestName: longestEl?.elementName || longestEl?.type || '—',
longestDur: longestEl ? ((longestEl.endFrame - longestEl.startFrame) / fps).toFixed(1) : '0',
};
}, [timelineElements, layers, durationInFrames, fps]);
const typeIcons: Record<string, React.ReactNode> = {
text: <Type size={8} className="text-violet-400" />,
image: <Image size={8} className="text-sky-400" />,
video: <Video size={8} className="text-rose-400" />,
audio: <Music size={8} className="text-amber-400" />,
};
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<BarChart3 size={12} className="text-violet-400" />
<span className="text-[10px] font-semibold text-white">Estadísticas del Proyecto</span>
</div>
<div className="grid grid-cols-3 gap-1">
<div className="bg-neutral-950 border border-neutral-800/50 rounded-lg p-2 text-center">
<div className="text-sm font-bold text-white">{stats.total}</div>
<div className="text-[7px] text-neutral-500 uppercase">Elementos</div>
</div>
<div className="bg-neutral-950 border border-neutral-800/50 rounded-lg p-2 text-center">
<div className="text-sm font-bold text-white">{stats.layerCount}</div>
<div className="text-[7px] text-neutral-500 uppercase">Capas</div>
</div>
<div className="bg-neutral-950 border border-neutral-800/50 rounded-lg p-2 text-center">
<div className="text-sm font-bold text-white">{stats.durationSec.toFixed(1)}s</div>
<div className="text-[7px] text-neutral-500 uppercase">Duración</div>
</div>
</div>
{/* Type breakdown */}
<div className="flex flex-wrap gap-1">
{Object.entries(stats.typeCounts).map(([type, count]) => (
<span
key={type}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-neutral-900 border border-neutral-800/50 text-[7px] text-neutral-400"
title={`${count} elemento(s) de tipo ${type}`}
>
{typeIcons[type] || <span className="w-2" />}
{type}: {count}
</span>
))}
</div>
{/* Longest element */}
{stats.total > 0 && (
<div className="text-[8px] text-neutral-600">
<Clock size={8} className="inline mr-1" />
Más largo: <span className="text-neutral-400">{stats.longestName}</span> ({stats.longestDur}s)
</div>
)}
</div>
);
};
+142
View File
@@ -0,0 +1,142 @@
import React from 'react';
import { Plus, Type, Square, Circle, Star, Sparkles } from 'lucide-react';
import { TimelineElement } from '../../types';
interface QuickTemplatesProps {
onAddElement: (element: Partial<TimelineElement>) => void;
}
const TEMPLATES = [
{
label: 'Título Grande',
icon: '📝',
element: {
type: 'text' as const,
content: 'Tu Título Aquí',
fontSize: 72,
fontWeight: '900',
color: '#FFFFFF',
textAlign: 'center' as const,
x: 50, y: 30,
width: 80, height: 20,
},
},
{
label: 'Subtítulo',
icon: '💬',
element: {
type: 'text' as const,
content: 'Subtítulo descriptivo',
fontSize: 32,
fontWeight: '400',
color: '#a1a1aa',
textAlign: 'center' as const,
x: 50, y: 55,
width: 70, height: 10,
},
},
{
label: 'CTA Button',
icon: '🔘',
element: {
type: 'text' as const,
content: 'Comprar Ahora →',
fontSize: 24,
fontWeight: '700',
color: '#FFFFFF',
textBackground: '#8b5cf6',
textAlign: 'center' as const,
borderRadius: 12,
x: 50, y: 80,
width: 40, height: 8,
},
},
{
label: 'Caption',
icon: '📰',
element: {
type: 'text' as const,
content: 'Texto informativo aquí',
fontSize: 18,
fontWeight: '500',
color: '#d4d4d8',
textAlign: 'left' as const,
x: 50, y: 70,
width: 60, height: 8,
},
},
{
label: 'Fondo Degradado',
icon: '🌈',
element: {
type: 'color' as const,
content: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
x: 50, y: 50,
width: 100, height: 100,
},
},
{
label: 'Overlay Oscuro',
icon: '🌙',
element: {
type: 'color' as const,
content: 'rgba(0,0,0,0.6)',
x: 50, y: 50,
width: 100, height: 100,
opacity: 60,
},
},
{
label: 'Viñeta',
icon: '📐',
element: {
type: 'color' as const,
content: 'radial-gradient(circle at center, transparent 40%, rgba(0,0,0,0.8) 100%)',
x: 50, y: 50,
width: 100, height: 100,
},
},
{
label: 'Texto Neón',
icon: '✨',
element: {
type: 'text' as const,
content: 'NEÓN',
fontSize: 64,
fontWeight: '900',
color: '#00ffcc',
textAlign: 'center' as const,
textShadow: '0 0 20px #00ffcc, 0 0 40px #00ffcc, 0 0 80px #00997a',
x: 50, y: 50,
width: 50, height: 15,
},
},
];
/**
* QuickElementTemplates — Pre-made element configurations for quick insertion.
* One click to add common design elements like titles, CTAs, overlays, etc.
*/
export const QuickElementTemplates: React.FC<QuickTemplatesProps> = ({ onAddElement }) => {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Sparkles size={12} className="text-amber-400" />
<span className="text-[10px] font-semibold text-white">Templates Rápidos</span>
</div>
<div className="grid grid-cols-2 gap-1">
{TEMPLATES.map(tmpl => (
<button
key={tmpl.label}
onClick={() => onAddElement(tmpl.element)}
title={tmpl.label}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/30 hover:bg-neutral-800 transition-all text-left group"
>
<span className="text-xs">{tmpl.icon}</span>
<span className="text-[8px] text-neutral-400 group-hover:text-white truncate">{tmpl.label}</span>
</button>
))}
</div>
</div>
);
};
+180
View File
@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import { Download, Loader2, X, Clock, CheckCircle, AlertCircle, FileVideo, Image as ImageIcon } from 'lucide-react';
interface RenderJob {
id: string;
status: 'queued' | 'rendering' | 'done' | 'error';
progress: number;
format: string;
width: number;
height: number;
downloadUrl?: string;
error?: string;
createdAt: number;
completedAt?: number;
fileSizeBytes?: number;
}
interface RenderHistoryPanelProps {
isOpen: boolean;
onClose: () => void;
}
/**
* RenderHistoryPanel — Shows past and active render jobs with progress,
* download links, and job status information.
*/
export const RenderHistoryPanel: React.FC<RenderHistoryPanelProps> = ({ isOpen, onClose }) => {
const [jobs, setJobs] = useState<RenderJob[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
setLoading(true);
fetch('/api/render/jobs')
.then(res => res.json())
.then(data => setJobs(data.jobs ?? []))
.catch(() => setJobs([]))
.finally(() => setLoading(false));
// SSE for real-time updates
const es = new EventSource('/api/render/events');
es.onmessage = (event) => {
try {
const { type, job } = JSON.parse(event.data);
if (type === 'job-update' && job) {
setJobs(prev => {
const idx = prev.findIndex(j => j.id === job.id);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = job;
return updated;
}
return [job, ...prev];
});
}
} catch {}
};
return () => es.close();
}, [isOpen]);
if (!isOpen) return null;
const formatSize = (bytes?: number) => {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
};
const formatTime = (ms: number) => {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
};
const statusIcon = (status: string) => {
switch (status) {
case 'queued': return <Clock size={12} className="text-amber-400" />;
case 'rendering': return <Loader2 size={12} className="text-violet-400 animate-spin" />;
case 'done': return <CheckCircle size={12} className="text-emerald-400" />;
case 'error': return <AlertCircle size={12} className="text-red-400" />;
default: return null;
}
};
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-neutral-900 border border-neutral-700 rounded-xl shadow-2xl w-[480px] max-h-[70vh] overflow-hidden" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<FileVideo size={16} className="text-violet-400" />
Historial de Renders
</h3>
<button onClick={onClose} title="Cerrar" className="text-neutral-500 hover:text-white p-1 rounded hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto max-h-[60vh] custom-scrollbar p-3 space-y-2">
{loading && (
<div className="text-center py-8 text-neutral-500 text-xs">
<Loader2 size={20} className="animate-spin mx-auto mb-2" />
Cargando...
</div>
)}
{!loading && jobs.length === 0 && (
<div className="text-center py-8 text-neutral-600 text-xs">
No hay renders aún. Haz clic en "Renderizar" para comenzar.
</div>
)}
{jobs.map(job => (
<div key={job.id} className="bg-neutral-950 border border-neutral-800 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{statusIcon(job.status)}
<span className="text-[10px] font-medium text-neutral-300 uppercase">
{job.format}
</span>
<span className="text-[9px] text-neutral-600 font-mono">
{job.width}×{job.height}
</span>
</div>
<span className="text-[9px] text-neutral-600 font-mono">
{new Date(job.createdAt).toLocaleTimeString()}
</span>
</div>
{/* Progress bar */}
{job.status === 'rendering' && (
<div className="w-full h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-violet-500 to-fuchsia-500 transition-all rounded-full"
style={{ width: `${job.progress}%` }}
/>
</div>
)}
{/* Status info */}
<div className="flex items-center justify-between text-[9px]">
{job.status === 'rendering' && (
<span className="text-violet-400 font-mono">{job.progress}%</span>
)}
{job.status === 'done' && (
<span className="text-emerald-400">
Completado en {formatTime((job.completedAt ?? 0) - job.createdAt)} {formatSize(job.fileSizeBytes)}
</span>
)}
{job.status === 'error' && (
<span className="text-red-400">{job.error}</span>
)}
{job.status === 'queued' && (
<span className="text-amber-400">En cola...</span>
)}
{/* Download button */}
{job.status === 'done' && job.downloadUrl && (
<a
href={job.downloadUrl}
download
title="Descargar"
className="flex items-center gap-1 px-2 py-1 bg-emerald-600/20 text-emerald-300 rounded hover:bg-emerald-600/30 transition-colors"
>
<Download size={10} />
<span>Descargar</span>
</a>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,56 @@
import React from 'react';
import { Smartphone, Monitor, Tablet } from 'lucide-react';
interface ResponsivePreviewProps {
mode: 'desktop' | 'tablet' | 'phone' | null;
onModeChange: (mode: 'desktop' | 'tablet' | 'phone' | null) => void;
}
const PREVIEW_MODES = [
{ value: null as null, label: 'Normal', icon: Monitor, scale: 1 },
{ value: 'tablet' as const, label: 'Tablet', icon: Tablet, scale: 0.65 },
{ value: 'phone' as const, label: 'Phone', icon: Smartphone, scale: 0.45 },
];
/**
* ResponsivePreviewToggle — Toggles canvas preview between desktop, tablet, and phone sizes.
* Shows a device frame indicator around the canvas preview.
*/
export const ResponsivePreviewToggle: React.FC<ResponsivePreviewProps> = ({
mode,
onModeChange,
}) => {
return (
<div className="flex items-center gap-0.5 bg-neutral-900/80 backdrop-blur-sm border border-neutral-800/40 rounded-lg p-0.5">
{PREVIEW_MODES.map(preset => {
const Icon = preset.icon;
const isActive = mode === preset.value;
return (
<button
key={preset.label}
onClick={() => onModeChange(isActive ? null : preset.value)}
title={`Preview: ${preset.label}`}
className={`p-1 rounded-md transition-all ${
isActive
? 'bg-violet-500/20 text-violet-300'
: 'text-neutral-600 hover:text-neutral-400'
}`}
>
<Icon size={12} />
</button>
);
})}
</div>
);
};
/**
* Returns the scale factor for a given responsive preview mode.
*/
export function getPreviewScale(mode: 'desktop' | 'tablet' | 'phone' | null): number {
switch (mode) {
case 'tablet': return 0.65;
case 'phone': return 0.45;
default: return 1;
}
}
+89
View File
@@ -0,0 +1,89 @@
import React, { useState, useEffect, useRef } from 'react';
import { Save, Check, Loader2, Cloud, CloudOff } from 'lucide-react';
interface SaveIndicatorProps {
/** Data to watch for changes — triggers save on change */
data: unknown;
/** Storage key for localStorage */
storageKey: string;
/** Debounce delay in ms */
debounceMs?: number;
}
type SaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
/**
* SaveIndicator — Shows auto-save status and persists data to localStorage.
* Renders a small pill with icon + text showing current save state.
*/
export const SaveIndicator: React.FC<SaveIndicatorProps> = ({
data,
storageKey,
debounceMs = 1500,
}) => {
const [status, setStatus] = useState<SaveStatus>('saved');
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isFirstRender = useRef(true);
useEffect(() => {
// Skip first render (initial load)
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setStatus('unsaved');
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setStatus('saving');
try {
localStorage.setItem(storageKey, JSON.stringify(data));
setStatus('saved');
} catch (e) {
console.warn('Auto-save failed:', e);
setStatus('error');
}
}, debounceMs);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [data, storageKey, debounceMs]);
const config: Record<SaveStatus, { icon: React.ReactNode; label: string; className: string }> = {
saved: {
icon: <Check size={10} />,
label: 'Guardado',
className: 'text-emerald-400/60 bg-emerald-500/5 border-emerald-500/10',
},
saving: {
icon: <Loader2 size={10} className="animate-spin" />,
label: 'Guardando...',
className: 'text-amber-400/60 bg-amber-500/5 border-amber-500/10',
},
unsaved: {
icon: <Cloud size={10} />,
label: 'Sin guardar',
className: 'text-neutral-500 bg-neutral-900/50 border-neutral-800',
},
error: {
icon: <CloudOff size={10} />,
label: 'Error',
className: 'text-red-400/60 bg-red-500/5 border-red-500/10',
},
};
const { icon, label, className } = config[status];
return (
<div
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[8px] font-medium transition-colors ${className}`}
title={`Estado: ${label}`}
>
{icon}
{label}
</div>
);
};
+151
View File
@@ -0,0 +1,151 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
interface ScrubInputProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
suffix?: string;
label?: string;
/** Sensitivity: how many pixels of drag = 1 unit change */
sensitivity?: number;
}
/**
* Numeric input with drag-to-scrub, scroll-to-adjust, and click-to-edit.
* Inspired by After Effects / Figma property inputs.
*/
export const ScrubInput: React.FC<ScrubInputProps> = ({
value,
onChange,
min = -Infinity,
max = Infinity,
step = 1,
suffix = '',
label,
sensitivity = 2,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dragStartRef = useRef<{ x: number; startValue: number } | null>(null);
// Click to edit
const handleDoubleClick = useCallback(() => {
setEditValue(String(Math.round(value * 10) / 10));
setIsEditing(true);
setTimeout(() => inputRef.current?.select(), 10);
}, [value]);
// Commit edit
const commitEdit = useCallback(() => {
const parsed = parseFloat(editValue);
if (!isNaN(parsed)) {
onChange(Math.max(min, Math.min(max, parsed)));
}
setIsEditing(false);
}, [editValue, onChange, min, max]);
// Handle key in edit mode
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
commitEdit();
} else if (e.key === 'Escape') {
setIsEditing(false);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const inc = e.shiftKey ? step * 10 : step;
const newVal = Math.min(max, parseFloat(editValue || '0') + inc);
setEditValue(String(Math.round(newVal * 10) / 10));
onChange(newVal);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const inc = e.shiftKey ? step * 10 : step;
const newVal = Math.max(min, parseFloat(editValue || '0') - inc);
setEditValue(String(Math.round(newVal * 10) / 10));
onChange(newVal);
}
}, [commitEdit, editValue, onChange, min, max, step]);
// Drag to scrub
const handlePointerDown = useCallback((e: React.PointerEvent) => {
if (isEditing) return;
e.preventDefault();
dragStartRef.current = { x: e.clientX, startValue: value };
setIsDragging(true);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [isEditing, value]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragStartRef.current || isEditing) return;
const deltaX = e.clientX - dragStartRef.current.x;
const multiplier = e.shiftKey ? 0.1 : 1; // Shift for fine control
const deltaValue = (deltaX / sensitivity) * step * multiplier;
const newValue = Math.max(min, Math.min(max, dragStartRef.current.startValue + deltaValue));
onChange(Math.round(newValue * 10) / 10);
}, [isEditing, sensitivity, step, min, max, onChange]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const totalDelta = Math.abs(e.clientX - dragStartRef.current.x);
// If barely moved, treat as a click → enter edit mode
if (totalDelta < 3) {
handleDoubleClick();
}
dragStartRef.current = null;
setIsDragging(false);
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
}, [handleDoubleClick]);
// Scroll to adjust
const handleWheel = useCallback((e: React.WheelEvent) => {
if (isEditing) return;
e.preventDefault();
const direction = e.deltaY > 0 ? -1 : 1;
const multiplier = e.shiftKey ? 10 : 1;
const newValue = Math.max(min, Math.min(max, value + direction * step * multiplier));
onChange(Math.round(newValue * 10) / 10);
}, [isEditing, value, step, min, max, onChange]);
return (
<div className="flex items-center gap-1.5">
{label && (
<span className="text-[9px] text-neutral-500 w-3 shrink-0 select-none">{label}</span>
)}
<div
className={`relative flex-1 bg-neutral-900 border rounded px-1.5 py-0.5 text-[10px] font-mono transition-all select-none ${
isDragging
? 'border-violet-500 bg-violet-500/10'
: isEditing
? 'border-violet-500'
: 'border-neutral-700/50 hover:border-neutral-600'
}`}
style={{ cursor: isEditing ? 'text' : 'ew-resize' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onDoubleClick={handleDoubleClick}
onWheel={handleWheel}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
className="w-full bg-transparent text-white text-[10px] font-mono outline-none"
autoFocus
/>
) : (
<span className="text-neutral-200 pointer-events-none">
{Math.round(value * 10) / 10}{suffix && <span className="text-neutral-500 ml-0.5">{suffix}</span>}
</span>
)}
</div>
</div>
);
};
+141
View File
@@ -0,0 +1,141 @@
import React from 'react';
import { X, Keyboard } from 'lucide-react';
interface ShortcutsOverlayProps {
isOpen: boolean;
onClose: () => void;
}
interface ShortcutGroup {
title: string;
shortcuts: { keys: string[]; desc: string }[];
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: 'Reproducción',
shortcuts: [
{ keys: ['Espacio'], desc: 'Play / Pausa' },
{ keys: ['←'], desc: 'Frame anterior' },
{ keys: ['→'], desc: 'Frame siguiente' },
{ keys: ['Home'], desc: 'Ir al inicio' },
{ keys: ['End'], desc: 'Ir al final' },
],
},
{
title: 'Edición',
shortcuts: [
{ keys: ['⌘', 'Z'], desc: 'Deshacer' },
{ keys: ['⌘', '⇧', 'Z'], desc: 'Rehacer' },
{ keys: ['⌘', 'C'], desc: 'Copiar elemento' },
{ keys: ['⌘', 'V'], desc: 'Pegar elemento' },
{ keys: ['⌘', '⌥', 'C'], desc: 'Copiar estilo' },
{ keys: ['⌘', '⌥', 'V'], desc: 'Pegar estilo' },
{ keys: ['D'], desc: 'Duplicar elemento' },
{ keys: ['Supr / ⌫'], desc: 'Eliminar elemento' },
],
},
{
title: 'Timeline',
shortcuts: [
{ keys: ['S'], desc: 'Dividir clip (Split)' },
{ keys: ['M'], desc: 'Añadir marcador' },
],
},
{
title: 'Canvas',
shortcuts: [
{ keys: ['⌘', '+'], desc: 'Zoom in' },
{ keys: ['⌘', ''], desc: 'Zoom out' },
{ keys: ['Ctrl', 'Scroll'], desc: 'Zoom con rueda' },
{ keys: ['Espacio', 'Drag'], desc: 'Pan / Mover canvas' },
{ keys: ['G'], desc: 'Grilla (regla de tercios)' },
{ keys: ['⇧', 'S'], desc: 'Zona segura' },
],
},
{
title: 'Elementos',
shortcuts: [
{ keys: ['↑↓←→'], desc: 'Mover elemento (1%)' },
{ keys: ['⇧', '↑↓←→'], desc: 'Mover elemento (5%)' },
{ keys: ['⌘', 'F'], desc: 'Buscar elementos' },
],
},
];
/**
* ShortcutsOverlay — Full-screen modal showing all keyboard shortcuts.
* Triggered with ? key or from a help button.
*/
export const ShortcutsOverlay: React.FC<ShortcutsOverlayProps> = ({ isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center"
onClick={onClose}
>
<div
className="bg-neutral-900 border border-neutral-700 rounded-2xl w-[640px] max-h-[80vh] shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-violet-500/10">
<Keyboard size={18} className="text-violet-400" />
</div>
<h2 className="text-sm font-bold text-white">Atajos de Teclado</h2>
</div>
<button
onClick={onClose}
title="Cerrar"
className="p-1.5 rounded-lg hover:bg-neutral-800 text-neutral-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto custom-scrollbar grid grid-cols-2 gap-6">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title}>
<h3 className="text-[10px] font-semibold text-violet-400 uppercase tracking-wider mb-3">
{group.title}
</h3>
<div className="space-y-1.5">
{group.shortcuts.map((shortcut) => (
<div
key={shortcut.desc}
className="flex items-center justify-between py-1 group"
>
<span className="text-[11px] text-neutral-400 group-hover:text-neutral-200 transition-colors">
{shortcut.desc}
</span>
<div className="flex items-center gap-0.5">
{shortcut.keys.map((key, ki) => (
<kbd
key={ki}
className="min-w-[22px] h-[22px] px-1.5 inline-flex items-center justify-center rounded-md bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-300 shadow-sm"
>
{key}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-neutral-800 shrink-0 flex items-center justify-center">
<span className="text-[10px] text-neutral-600">
Presiona <kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-neutral-400 font-mono">?</kbd> para abrir/cerrar
</span>
</div>
</div>
</div>
);
};
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
import { TimelineElement } from '../../types';
interface TextStylePresetsProps {
element: TimelineElement;
onUpdate: (updates: Partial<TimelineElement>) => void;
}
const TEXT_STYLE_PRESETS = [
{
name: '📢 Título',
desc: 'Título grande y bold',
styles: { fontSize: 72, fontWeight: 800, textTransform: 'uppercase' as const, letterSpacing: 2, lineHeight: 1.1 },
},
{
name: '📝 Subtítulo',
desc: 'Subtítulo medio y semi-bold',
styles: { fontSize: 48, fontWeight: 600, textTransform: 'none' as const, letterSpacing: 0, lineHeight: 1.3 },
},
{
name: '💬 Caption',
desc: 'Texto de caption pequeño',
styles: { fontSize: 28, fontWeight: 400, textTransform: 'none' as const, letterSpacing: 1, lineHeight: 1.4 },
},
{
name: '✨ Elegante',
desc: 'Texto elegante con spacing',
styles: { fontSize: 56, fontWeight: 300, textTransform: 'uppercase' as const, letterSpacing: 8, lineHeight: 1.5 },
},
{
name: '🔥 Impacto',
desc: 'Bold con sombra fuerte',
styles: { fontSize: 64, fontWeight: 900, textTransform: 'uppercase' as const, letterSpacing: -1, lineHeight: 1.0, shadowOffset: 4, shadowBlur: 8 },
},
{
name: '🎀 Delicado',
desc: 'Light italic suave',
styles: { fontSize: 42, fontWeight: 300, fontStyle: 'italic' as const, textTransform: 'none' as const, letterSpacing: 2, lineHeight: 1.6 },
},
{
name: '📊 Dato',
desc: 'Monospace para datos/números',
styles: { fontSize: 36, fontWeight: 500, fontFamily: 'JetBrains Mono', textTransform: 'none' as const, letterSpacing: 0, lineHeight: 1.3 },
},
{
name: '🏷️ Tag',
desc: 'Badge pequeño uppercase',
styles: { fontSize: 20, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: 4, lineHeight: 1.2, textBackgroundColor: 'rgba(139,92,246,0.3)', textBackgroundPadding: 8, textBackgroundRadius: 4 },
},
];
/**
* TextStylePresets — Quick-apply pre-designed text style configurations.
* Each preset adjusts fontSize, fontWeight, transform, spacing, and optional effects.
*/
export const TextStylePresets: React.FC<TextStylePresetsProps> = ({ element, onUpdate }) => {
return (
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 block">Estilos Prediseñados</span>
<div className="grid grid-cols-2 gap-1">
{TEXT_STYLE_PRESETS.map((preset) => (
<button
key={preset.name}
onClick={() => onUpdate(preset.styles)}
title={preset.desc}
className="py-1.5 px-2 rounded-lg text-[8px] font-medium bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-violet-300 hover:border-violet-500/30 transition-all text-left"
>
{preset.name}
</button>
))}
</div>
</div>
);
};
+73
View File
@@ -0,0 +1,73 @@
import React, { useState, useEffect } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
export type ToastType = 'success' | 'error' | 'info';
interface ToastProps {
message: string;
type?: ToastType;
duration?: number;
onDismiss: () => void;
}
export const Toast: React.FC<ToastProps> = ({
message,
type = 'success',
duration = 3000,
onDismiss
}) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Animate in
requestAnimationFrame(() => setIsVisible(true));
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(onDismiss, 300); // Wait for fade out animation
}, duration);
return () => clearTimeout(timer);
}, [duration, onDismiss]);
const styles = {
success: {
bg: 'bg-emerald-950/90 border-emerald-700/50',
icon: <CheckCircle size={18} className="text-emerald-400 shrink-0" />,
text: 'text-emerald-200'
},
error: {
bg: 'bg-rose-950/90 border-rose-700/50',
icon: <AlertCircle size={18} className="text-rose-400 shrink-0" />,
text: 'text-rose-200'
},
info: {
bg: 'bg-violet-950/90 border-violet-700/50',
icon: <Info size={18} className="text-violet-400 shrink-0" />,
text: 'text-violet-200'
}
};
const style = styles[type];
return (
<div
className={`fixed bottom-6 right-6 z-[9999] flex items-center gap-3 px-5 py-3.5 rounded-xl border shadow-2xl backdrop-blur-xl transition-all duration-300 ${style.bg} ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
}`}
>
{style.icon}
<span className={`text-sm font-medium ${style.text}`}>{message}</span>
<button
onClick={() => {
setIsVisible(false);
setTimeout(onDismiss, 300);
}}
title="Cerrar notificación"
className="text-neutral-500 hover:text-white p-0.5 rounded transition-colors ml-2"
>
<X size={14} />
</button>
</div>
);
};
+75
View File
@@ -0,0 +1,75 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { CheckCircle2, AlertCircle, Info, Copy, Scissors, X } from 'lucide-react';
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info' | 'copy' | 'cut';
duration?: number;
}
interface ToastContextValue {
showToast: (message: string, type?: Toast['type'], duration?: number) => void;
}
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} });
export const useToast = () => useContext(ToastContext);
const ICONS: Record<Toast['type'], React.ReactNode> = {
success: <CheckCircle2 size={14} className="text-emerald-400" />,
error: <AlertCircle size={14} className="text-red-400" />,
info: <Info size={14} className="text-sky-400" />,
copy: <Copy size={14} className="text-violet-400" />,
cut: <Scissors size={14} className="text-amber-400" />,
};
const COLORS: Record<Toast['type'], string> = {
success: 'border-emerald-500/30 bg-emerald-500/5',
error: 'border-red-500/30 bg-red-500/5',
info: 'border-sky-500/30 bg-sky-500/5',
copy: 'border-violet-500/30 bg-violet-500/5',
cut: 'border-amber-500/30 bg-amber-500/5',
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const counterRef = useRef(0);
const showToast = useCallback((message: string, type: Toast['type'] = 'info', duration = 2000) => {
const id = `toast-${++counterRef.current}`;
setToasts(prev => [...prev, { id, message, type, duration }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, duration);
}, []);
const dismiss = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast container — bottom-center */}
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-[100] flex flex-col-reverse gap-2 items-center pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex items-center gap-2 px-4 py-2 rounded-xl border backdrop-blur-md shadow-xl animate-in ${COLORS[toast.type]}`}
>
{ICONS[toast.type]}
<span className="text-xs text-neutral-200 font-medium">{toast.message}</span>
<button
onClick={() => dismiss(toast.id)}
className="ml-1 text-neutral-500 hover:text-neutral-300 transition-colors"
title="Cerrar"
>
<X size={10} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};