Initial commit — Bradly branding editor platform
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user