Initial commit — Bradly branding editor platform

This commit is contained in:
2026-06-02 03:27:03 -05:00
commit b135a70cc7
180 changed files with 43160 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# GROQ_API_KEY: Required for Groq Whisper V3 transcriptions.
GROQ_API_KEY="MY_GROQ_API_KEY"
+41
View File
@@ -0,0 +1,41 @@
# Dependencies
node_modules/
# Build output
build/
dist/
*.cjs
*.cjs.map
# Coverage
coverage/
# Environment variables
.env
.env.local
.env.*.local
!.env.example
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Editor directories
.vscode/
.idea/
*.swp
*.swo
# Uploaded media (user-generated, not tracked)
uploads/
renders/
# Antigravity IDE
.antigravity/
# TypeScript cache
*.tsbuildinfo
+159
View File
@@ -0,0 +1,159 @@
# Remix — Editor de Branding Automatizado
## ¿Qué es Remix?
Remix es una plataforma SaaS de creación de contenido visual (videos e imágenes) que automatiza la aplicación de la identidad de marca. En lugar de depender de un diseñador para cada pieza de contenido, Remix permite que cualquier persona dentro de una organización genere material visualmente consistente — respetando estrictamente los colores, tipografías, logos, intros, outros y reglas visuales definidas por la marca.
La plataforma está pensada para equipos de marketing, agencias creativas y emprendedores que necesitan producir contenido de marca de forma rápida, consistente y a escala, sin sacrificar calidad visual.
---
## El Problema que Resuelve
Crear contenido de marca de forma consistente es costoso y lento:
- **Inconsistencia visual:** Cada persona aplica los colores, fuentes y logos de forma diferente, erosionando la identidad de marca.
- **Dependencia del diseñador:** Cualquier pieza, por simple que sea, requiere pasar por un diseñador que conozca las reglas del brand.
- **Fragmentación de herramientas:** Los equipos saltan entre Canva, Premiere, After Effects, Figma y hojas de cálculo para planificar, diseñar y publicar contenido.
- **Repetición manual:** Las intros, outros, marcas de agua y elementos recurrentes se aplican manualmente en cada proyecto.
Remix centraliza todo esto en un solo flujo: defines tu marca una vez, y toda la producción de contenido la respeta automáticamente.
---
## Concepto Central: Design MD
El corazón de Remix es el concepto de **Design MD** (Markdown de Diseño) — un conjunto de reglas programáticas que definen la identidad visual de una marca de forma estricta y reusable.
Un Design MD incluye:
- **Paleta de colores:** Color primario, secundario y de texto.
- **Tipografía:** Fuente base, fuente de títulos, fuente de subtítulos, con tamaños predeterminados.
- **Logo y posicionamiento:** Imagen del logo con posición configurable en el canvas.
- **Marco visual:** Grosor del borde/frame que envuelve el contenido.
- **Videos de marca:** Intro y outro pregrabados que se insertan automáticamente.
- **Audio de marca:** Música o jingle de fondo con control de volumen y fade in/out.
- **Transiciones por defecto:** Animaciones de entrada y salida que se aplican a los elementos.
- **Redes sociales:** Handles de Instagram, TikTok, Twitter, YouTube, etc.
La clave es que el Design MD **no es un template visual** — es un *plano arquitectónico*. Todo el contenido generado en la plataforma lo hereda automáticamente, garantizando consistencia sin intervención manual.
---
## Flujos Principales
### 1. Panel de Marca (Dashboard)
El punto de entrada de la plataforma. Aquí el usuario puede:
- **Crear y gestionar múltiples marcas** — cada una con su propio Design MD independiente.
- **Ver un preview en vivo** de la identidad visual de cada marca (imagen estática o video con intro/outro).
- **Seleccionar el formato de salida** (video o imagen) antes de iniciar un proyecto.
- **Acceder a proyectos guardados** para continuar editando.
- **Elegir entre dos modos de creación:** Express (rápido y guiado) o Editor Pro (control total).
### 2. Configuración de Marca (Brand Architecture)
Un editor dedicado donde se define el Design MD completo de una marca. Está organizado en pestañas:
- **Información:** Nombre de la empresa, industria, tagline, redes sociales.
- **Visual y Colores:** Paleta de colores (primario, secundario, texto), logo, grosor del marco.
- **Tipografía:** Fuente base y fuentes específicas por jerarquía (título, subtítulo, párrafo) con selector de Google Fonts.
- **Video y Audio:** Upload de videos de intro/outro, audio de marca, configuración de duraciones y transiciones.
- **Contenido:** Creación de piezas de contenido reutilizables (tarjetas de texto, badges sociales, badges de logo) con editor visual de posicionamiento.
- **Plantillas:** Creación y gestión de plantillas personalizadas que respetan el Design MD, usando un editor visual tipo Figma.
El panel incluye un preview en vivo a la derecha que se actualiza en tiempo real conforme se modifican los parámetros.
### 3. Editor Express ⚡
Un flujo simplificado de creación basado en **plantillas con escenas**:
- El usuario selecciona una plantilla (post social, anuncio, historia, etc.) de una galería.
- La plantilla define un storyboard de escenas con campos editables prellenados con variables de la marca.
- El usuario solo modifica el contenido (textos, imágenes) — el branding se aplica automáticamente.
- Soporta múltiples aspect ratios (9:16, 16:9, 1:1, 4:5).
- Las escenas se pueden reordenar y sus duraciones se pueden ajustar.
- Los campos se auto-rellenan con datos de la marca (nombre, logo, redes sociales).
### 4. Editor Pro (Studio)
Un editor de video/imagen profesional con control granular:
- **Canvas tipo Figma** con zoom, pasteboard y guías de snap.
- **Timeline multi-capa** para componer elementos en el tiempo.
- **Elementos soportados:** Texto, imágenes, videos, audio, stickers, formas (rectángulo, círculo, triángulo, estrella, etc.), colores de fondo.
- **Propiedades por elemento:** Posición, tamaño, rotación, opacidad, color, tipografía, sombras, bordes, filtros, modos de mezcla, chroma key, y más.
- **Sistema de animación:** Transiciones de entrada/salida (fade, slide, scale, blur, spin, flip, typewriter, bounce) con keyframes multi-punto.
- **Capas con control:** Visibilidad, bloqueo, opacidad, color de etiqueta, mute/solo para audio.
- **Media Library** con soporte para uploads locales y búsqueda de stock (Pexels).
- **Subtítulos automáticos** con transcripción vía Whisper (API de Groq).
- **Renderizado real** del proyecto a video/imagen exportable usando Remotion como motor de render en el servidor.
### 5. Malla de Contenidos (Content Grid)
Un sistema de planificación editorial para organizar el contenido de cada marca:
- **Vista de calendario** mensual para visualizar publicaciones programadas.
- **Vista de grilla** con tarjetas de contenido organizadas por estado.
- **Vista de lista** detallada con toda la información.
- **Estados de contenido:** Idea → Borrador → En Revisión → Aprobado → Programado → Publicado.
- **Pilares de contenido** personalizables con colores para categorizar el tipo de contenido.
- **Plataformas destino:** Instagram, TikTok, YouTube, Facebook, Twitter, LinkedIn.
- **Vinculación con proyectos:** Cada pieza de contenido puede asociarse a un proyecto del editor para acceder directamente.
---
## Stack Tecnológico
| Capa | Tecnología |
|---|---|
| Frontend | React 19 + TypeScript + Vite |
| Estilos | Tailwind CSS v4 |
| Animaciones | Motion (Framer Motion) |
| Motor de Render | Remotion v4 (composición + renderizado server-side) |
| Iconos | Lucide React |
| Backend | Express.js (servidor unificado con Vite en desarrollo) |
| Uploads | Multer (disco local) |
| Transcripción | Groq API (Whisper Large v3) |
| Stock Media | Pexels API |
| Persistencia | LocalStorage (frontend) + PostgreSQL (esquema preparado para SaaS) |
---
## Arquitectura Multi-Editor
Remix implementa tres editores que comparten una base arquitectónica común:
| Editor | Propósito | Audiencia |
|---|---|---|
| **Studio** | Editor profesional completo con timeline | Diseñadores, editores de video |
| **Express** | Creación rápida basada en plantillas | Equipos de marketing, community managers |
| **Template Builder** | Creación de plantillas personalizadas | Brand managers, agencias |
Los tres editores comparten componentes de UI, hooks de interacción (drag & resize) y el modelo de datos subyacente. El Template Builder convierte entre el formato de plantillas (escenas + campos) y el formato de timeline (elementos + capas) de forma transparente.
---
## Modelo Multi-Tenant
La plataforma está diseñada para manejar múltiples marcas (empresas) de forma aislada:
- Cada marca tiene su propio Design MD, contenido, plantillas y proyectos.
- El esquema de base de datos está preparado para un modelo SaaS con tenants, usuarios con roles (admin, editor, viewer) y activos por empresa.
- Actualmente la persistencia del frontend es via LocalStorage, pero la estructura está lista para migrar a un backend con autenticación.
---
## ¿Para Quién es Remix?
- **Agencias de marketing** que manejan múltiples clientes y necesitan producir contenido de marca a escala.
- **Equipos internos de marketing** que quieren empoderar a cualquier miembro para crear contenido sin depender de un diseñador.
- **Emprendedores y small businesses** que necesitan contenido profesional pero no tienen presupuesto para un equipo creativo.
- **Freelancers y creadores de contenido** que buscan mantener consistencia visual en sus publicaciones.
---
## Visión a Futuro
Remix busca convertirse en la plataforma de referencia para la automatización de branding visual — donde definir una marca una vez sea suficiente para generar contenido infinito, consistente y profesional, sin intervención de IA generativa en el diseño (las reglas son estrictas y programáticas, no probabilísticas).
+132
View File
@@ -0,0 +1,132 @@
# Development Decisions
- **Modularization:** Architect the application using small, modular, well-encapsulated components organized into appropriate subdirectories under `/src/components`. Avoid creating massive monolithic files (especially for complex UI like the timeline or property panels). Break them down into logical child components (e.g., `TimelineRuler`, `TimelineTrack`, `TimelineControls`). Keep all components focused on a single responsibility.
- **Tooltips:** Always include `title` attributes (tooltips) on every actionable button or icon button in the editor panels to improve accessibility and user experience.
---
# Component Reuse & Abstraction Rules
> **Golden Rule:** If a pattern or UI control exists in one editor and is needed in another, ALWAYS abstract it into a shared component/hook before duplicating code. Never copy-paste drag logic, inspector controls, or canvas patterns.
## Shared Hooks (`/src/hooks/`)
| Hook | Purpose | Used By |
|---|---|---|
| `useDragResize` | Pointer-based drag & resize on any canvas (pointer capture → delta % → clamp → snap) | `BuilderCanvas`, and should be used by `BrandContentEditor`. Future: `CompositionElement`. |
**When to use `useDragResize`:** Any time you need to move or resize elements on a canvas using pointer events. Do NOT implement custom `pointerDown/Move/Up` state machines — use this hook.
## Shared UI Components (`/src/components/ui/`)
| Component | Purpose | Generic Interface | Used By |
|---|---|---|---|
| `FieldInspector` | Position grid (X/Y/W/H %), alignment, font, color, opacity | `FieldPosition` + `FieldTextStyle` | `FieldConfigPanel` (Template Builder), `SceneConfigurator`. Future: abstracted from `ElementPropertiesPanel`. |
| `AlignmentTools` | Quick-align to canvas edges/center | `onAlign: ({x?, y?}) => void` | `FieldInspector`. Future: `ElementPropertiesPanel`. |
| `FontPicker` | Google Fonts selector with search, recents, categories | `value: string, onChange: (font) => void` | `FieldInspector`, `FieldConfigPanel`, `ElementPropertiesPanel` |
| `TextStylePresets` | Quick-apply text style configurations | `TimelineElement` (needs future abstraction) | `ElementPropertiesPanel` |
| `CanvasWorkspace` | Figma-like pasteboard with aspect ratio frame + overlay layer | Generic wrapper | `StudioWorkspace`. |
| `CanvasZoomControls` | Zoom in/out/reset UI | Zoom state props | `StudioWorkspace` |
| `CollapsibleSection` | Collapsible panel section with badge for active count | `title, badge?, defaultOpen?, children` | `ElementPropertiesPanel`, `GlobalSettingsPanel`, `FieldSchemaPanel`, `FieldConfigPanel` |
## UX Pattern: Basic / Advanced
**Rule:** Any configuration panel with more than ~6 controls MUST split into "Basic" (always visible) and "Advanced" (collapsible, closed by default).
- Use `CollapsibleSection` for the advanced sections.
- The `badge` prop should count active/modified options so users know something is configured without opening.
- Group related advanced controls into named sections (e.g., "Tipografía Avanzada", "Color Avanzado", "Efectos de Texto").
**Current splits:**
| Panel | Basic | Advanced |
|---|---|---|
| `ElementPropertiesPanel` (text) | Color, Recientes, Tamaño, Fuente, Alineación, B/I/U/S | ▶ Color Avanzado, ▶ Tipografía Avanzada, ▶ Efectos de Texto |
| `GlobalSettingsPanel` | Background colors, Gradients | ▶ Herramientas, ▶ Opciones de Fondo, ▶ Vista |
| `FieldConfigPanel` | Etiqueta, Naturaleza, Tipo, Posición (FieldInspector) | ▶ Reglas de Validación |
## Abstraction Checklist
Before creating any new editor panel, canvas, or property inspector, check:
1. **Does `useDragResize` cover the drag pattern?** → Use it, don't create custom pointer handlers.
2. **Does `FieldInspector` cover the property controls?** → Use it for position/text/font editing.
3. **Does `AlignmentTools` cover alignment?** → Use it via `onAlign` callback.
4. **Does `FontPicker` exist?** → Always use it for font selection, never create inline font dropdowns.
5. **Does `CanvasWorkspace` cover the canvas wrapper?** → Use it for the pasteboard + aspect frame.
6. **Does the panel have >6 controls?** → Use `CollapsibleSection` to split basic/advanced.
## Interface Design Rules
- **Generic interfaces over coupled ones.** Use `onAlign: ({x?, y?}) => void` instead of `onUpdate: (Partial<TimelineElement>) => void`. This allows any data model to use the component.
- **Percentage-based coordinates.** All canvas positions use 0-100% relative to container. Never use pixels for position storage.
- **Brand context props.** Pass `brandFont`, `brandColors[]` to inspectors so they can show brand-aware quick-picks.
---
# Editor Architecture
## Three Editors, Shared Foundation
| Editor | Purpose | Canvas | Properties | Context |
|---|---|---|---|---|
| **Studio** (`StudioEditor`) | Full video editor with timeline | `StudioWorkspace` + `CompositionElement` | `ElementPropertiesPanel` (2549 LOC monolith) | `EditorProvider` |
| **Express** (`ExpressEditor`) | Simplified brand template usage | Remotion Player | `ExpressStylePanel` | N/A |
| **Template Builder** (`TemplateBuilder`) | Create/edit brand templates | `BuilderCanvas` (lightweight) | `FieldConfigPanel` + `FieldSchemaPanel` | `TemplateBuilderContext` |
## Template Builder Architecture
The Template Builder uses its own `TemplateBuilderContext` (NOT `EditorProvider`) to manage `TemplateField[]` directly.
**Key concept:** A template is two artifacts simultaneously:
1. **Layout fijo** — visual composition (positions, styles, backgrounds) inherited from DesignMD.
2. **Esquema de campos** — typed list of editable slots, brand variables, and static elements.
**TemplateField nature types:**
| Nature | Purpose | Badge | Generates form field? |
|---|---|---|---|
| `static` | Fixed decorative element | none | ❌ |
| `brand-variable` | Auto-fills from DesignMD | 🏷️ "auto" violet | ❌ |
| `editable-slot` | User fills in production | 🏷️ label, blue dashed | ✅ |
**Component layout:**
- Left: `FieldSchemaPanel` — field list by nature, counter, add buttons
- Center: `BuilderCanvas` (design view) or `FormPreviewPanel` (form preview)
- Right: `FieldConfigPanel` — per-field properties (label, nature, type, position, rules)
- Bottom (video only): `SceneComposer`
**Data flow:** `TemplateField[]` → (save) → `ExpressScene.fields` + backward-compat `editableFields` → (Express) → `compileExpressToTimeline``TimelineElement[]` for Remotion.
## Data Model Adapter (`src/utils/builderAdapter.ts`)
**DEPRECATED for Template Builder.** The builder now uses `TemplateField[]` directly via `TemplateBuilderContext`.
Kept only for legacy templates. Legacy conversion functions:
- `fieldsToElements()` — Load: `ExpressField[]``TimelineElement[]`
- `elementsToFields()` — Save: `TimelineElement[]``ExpressField[]`
New templates save both `scene.fields` (TemplateField[]) and `scene.editableFields` (ExpressField[]) for backward compatibility.
## Format-Aware UI
- Templates can be **video** or **image** format.
- Format is chosen BEFORE entering the builder (inline picker in `BrandTabTemplates`).
- Image format hides: scene type selector, duration, timeline (SceneComposer), intro/outro, audio toggles.
- Video format shows all features.
## Canvas Patterns
All canvases should follow this pattern:
1. **Container ref** for `getBoundingClientRect()` calculations
2. **`useDragResize` hook** for pointer interactions
3. **Percentage-based positioning** (0-100%) stored in data model
4. **Snap guides** rendered as absolute-positioned lines in the canvas
5. **Selection state** managed by parent, passed down as `selectedId`
## Known Technical Debt
- `ElementPropertiesPanel` (2549 LOC) is a monolith coupled to `TimelineElement`. Should be broken into sections using `FieldInspector` pattern.
- `StudioWorkspace` + `CompositionElement` have their own drag logic that predates `useDragResize`. Should migrate in a future refactor.
- `TextStylePresets` is coupled to `TimelineElement`. Should be abstracted like `AlignmentTools` was.
- `BuilderScenePanel` and `SceneConfigurator` are deprecated — replaced by `FieldSchemaPanel` and `FieldConfigPanel`.
+104
View File
@@ -0,0 +1,104 @@
# Bradly — Automated Branding Content Editor
> Create on-brand video and image content at scale — without a designer.
## Overview
Bradly (internally **Remix**) is a SaaS platform for automated branded content creation. It lets marketing teams, agencies, and creators produce visually consistent videos and images by defining brand rules once (Design MD) and applying them automatically across all content.
### Key Features
- **Design MD System** — Define colors, typography, logos, intros/outros, audio, and transitions as a programmatic brand blueprint.
- **Multi-Editor Architecture** — Three editors sharing a common foundation:
- **Studio** — Professional timeline-based video/image editor with Figma-like canvas.
- **Express** — Template-driven rapid creation for non-designers.
- **Template Builder** — Visual template editor for brand managers.
- **Server-Side Rendering** — Export production-ready videos and images via Remotion.
- **Content Grid** — Editorial calendar with content planning workflow (Idea → Published).
- **Multi-Brand Support** — Manage multiple brands with isolated identities and assets.
- **Batch Production** — Generate multiple content pieces from CSV data in one go.
## Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React 19 + TypeScript + Vite |
| Styling | Tailwind CSS v4 |
| Animations | Motion (Framer Motion) |
| Render Engine | Remotion v4 (composition + server-side rendering) |
| Icons | Lucide React |
| Backend | Express.js (unified server with Vite in dev) |
| Uploads | Multer (local disk) |
| Transcription | Groq API (Whisper Large v3) |
| Stock Media | Pexels API |
| Persistence | LocalStorage (frontend) + PostgreSQL (SaaS-ready schema) |
## Getting Started
### Prerequisites
- **Node.js** v18+ (recommended: v20+)
- **npm** v9+
### Installation
```bash
# Clone the repository
git clone git@github.com:kevinguevara/Bradly.git
cd Bradly
# Install dependencies
npm install
# Configure environment
cp .env.example .env
# Edit .env with your API keys
```
### Environment Variables
| Variable | Description |
|---|---|
| `GEMINI_API_KEY` | Google Gemini AI API key |
| `GROQ_API_KEY` | Groq Whisper V3 transcription key |
| `APP_URL` | Application host URL |
### Development
```bash
npm run dev
```
The dev server starts with Express + Vite middleware on `http://localhost:3000`.
### Production Build
```bash
npm run build
npm start
```
## Project Structure
```
├── src/
│ ├── components/ # UI components organized by feature
│ │ ├── dashboard/ # Brand dashboard & management
│ │ ├── editor/ # Studio editor (canvas, timeline, layers)
│ │ ├── express/ # Express template editor
│ │ ├── export/ # Export & render management
│ │ ├── templates/ # Template Builder
│ │ └── ui/ # Shared UI components
│ ├── context/ # React contexts (Editor, TemplateBuilder)
│ ├── hooks/ # Custom hooks (useDragResize, etc.)
│ ├── utils/ # Utilities & adapters
│ ├── types.ts # TypeScript type definitions
│ └── App.tsx # Main application component
├── server.ts # Express server with Remotion rendering
├── schema.sql # PostgreSQL database schema
└── index.html # Entry point
```
## License
Proprietary — All rights reserved.
+84
View File
@@ -0,0 +1,84 @@
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
import uuid
import datetime
# Inicialización de la API de prueba (FastAPI)
app = FastAPI(
title="Design MD Render API",
description="Motor central que expone las variables de marca para Remotion",
version="1.0.0"
)
# --- Modelos Pydantic (Validación de Esquema) ---
class DesignMDModel(BaseModel):
primary_color: str
secondary_color: str
text_color: str
base_font: str
logo_url: str
frame_thickness: int
class RenderJobRequest(BaseModel):
company_id: str
raw_video_url: str
platform_format: str # Ej: '9:16' (Reels/TikTok) o '1:1' (Facebook/IG)
custom_text: str
class RenderJobResponse(BaseModel):
job_id: str
status: str
estimated_time: int
message: str
# --- Endpoints ---
@app.get("/api/companies/{company_id}/design-md", response_model=DesignMDModel)
async def get_design_md(company_id: str):
"""
Simula la obtención de las directrices estrictas de marca ("Design MD")
desde PostgreSQL. Remotion consultará este Endpoint para sus props.
"""
# En un entorno real, ejecutaríamos: SELECT * FROM design_md WHERE company_id = company_id
# Simulamos la respuesta de la base de datos:
return DesignMDModel(
primary_color="#D946EF", # Fuchsia
secondary_color="#1E1B4B", # Deep Indigo
text_color="#FFFFFF",
base_font="Inter",
logo_url="https://upload.wikimedia.org/wikipedia/commons/a/a7/React-icon.svg",
frame_thickness=16
)
@app.post("/api/render", response_model=RenderJobResponse)
async def request_automated_render(req: RenderJobRequest):
"""
Endpoint para que la UI o un webhook solicite un nuevo render.
Aquí se enviaría un mensaje a una cola (ej. RabbitMQ, Redis, Celery)
para que un Worker inicie la compilación de Remotion en la nube.
"""
job_id = str(uuid.uuid4())
# 1. Obtenemos el DesignMD vinculado a la compañía
# design_md = get_design_md_from_db(req.company_id)
# 2. Preparamos el payload (props) para el bundle de Remotion:
# remotion_props = {
# "videoUrl": req.raw_video_url,
# "designMD": design_md,
# "textOverlay": req.custom_text
# }
# 3. Enqueue Job
# queue.push(job_id, remotion_props, req.platform_format)
return RenderJobResponse(
job_id=job_id,
status="queued",
estimated_time=45,
message="El trabajo ha sido enviado al motor de renderizado basado en lambdas/contenedores."
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Remix — Editor de Branding Automatizado</title>
<meta name="description" content="Plataforma SaaS para automatizar el branding visual de tu empresa. Crea videos e imágenes de marca con un sistema de diseño estricto usando Remotion." />
<meta property="og:title" content="Remix — Editor de Branding Automatizado" />
<meta property="og:description" content="Automatiza tu branding visual con Design MD y Remotion." />
<meta property="og:type" content="website" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2280%22>🎬</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{"name":"Remix: Design MD Renderer PoC","description":"Prueba de concepto para plataforma SaaS de automatización de branding con Remotion.","requestFramePermissions":[],"majorCapabilities":["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]}
+6913
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx server.ts",
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
"start": "node dist/server.cjs",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@google/genai": "^2.4.0",
"@remotion/bundler": "^4.0.468",
"@remotion/player": "^4.0.468",
"@remotion/renderer": "^4.0.468",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"file-saver": "^2.0.5",
"form-data": "^4.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"multer": "^2.1.1",
"papaparse": "^5.5.3",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"remotion": "^4.0.468",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/file-saver": "^2.0.7",
"@types/multer": "^2.1.0",
"@types/node": "^22.14.0",
"@types/papaparse": "^5.5.2",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}
+47
View File
@@ -0,0 +1,47 @@
-- PostgreSQL Schema for SaaS Branding Automation (Design MD)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 1. Empresas (Tenants)
CREATE TABLE companies (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- 2. Usuarios de la plataforma (Vinculados a empresas)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user', -- 'admin', 'editor', 'viewer'
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- 3. Design MD (Sistema de diseño estricto)
-- Almacena las "máscaras" y variables programáticas de cada marca.
CREATE TABLE design_md (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID UNIQUE REFERENCES companies(id) ON DELETE CASCADE,
primary_color VARCHAR(7) DEFAULT '#000000',
secondary_color VARCHAR(7) DEFAULT '#FFFFFF',
text_color VARCHAR(7) DEFAULT '#FFFFFF',
base_font VARCHAR(100) DEFAULT 'Inter',
logo_url TEXT,
frame_thickness INTEGER DEFAULT 12,
watermark_opacity DECIMAL(3,2) DEFAULT 1.0,
guidelines_notes TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- 4. Activos/Proyectos (Raw files y renders)
CREATE TABLE assets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
file_url TEXT NOT NULL,
asset_type VARCHAR(50) NOT NULL, -- 'raw_video', 'rendered_video'
target_platform VARCHAR(50), -- 'tiktok', 'reels', 'facebook'
status VARCHAR(50) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'failed'
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
+321
View File
@@ -0,0 +1,321 @@
import express from "express";
import path from "path";
import fs from "fs";
import crypto from "crypto";
import { createServer as createViteServer } from "vite";
import multer from "multer";
// ═══ Uploads directory ═══
const UPLOADS_DIR = path.join(process.cwd(), "uploads");
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
// ─── Multer: memory storage for transcription (existing) ───
const memoryUpload = multer({ storage: multer.memoryStorage() });
// ─── Multer: disk storage for persistent media uploads ───
const diskStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname) || '.bin';
const id = crypto.randomUUID();
cb(null, `${id}${ext}`);
},
});
const mediaUpload = multer({
storage: diskStorage,
limits: {
fileSize: 50 * 1024 * 1024, // 50 MB max
},
fileFilter: (_req, file, cb) => {
const allowed = /^(image|video|audio)\//;
if (allowed.test(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Unsupported file type: ${file.mimetype}`));
}
},
});
async function startServer() {
const app = express();
const PORT = 3000;
// Add JSON parser with generous limit for render payloads (timelineElements can be large)
app.use(express.json({ limit: '50mb' }));
// ═══ Serve uploaded media files ═══
app.use("/api/media", express.static(UPLOADS_DIR, {
maxAge: "1d",
immutable: true,
}));
// ═══ Upload media file (persistent storage) ═══
app.post("/api/upload", mediaUpload.single("file"), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No file provided" });
}
const url = `/api/media/${req.file.filename}`;
console.log(`📁 Uploaded: ${req.file.originalname}${url} (${(req.file.size / 1024).toFixed(1)} KB)`);
res.json({
url,
originalName: req.file.originalname,
size: req.file.size,
mimetype: req.file.mimetype,
});
});
// ═══ Transcription (existing) ═══
app.post("/api/transcribe", memoryUpload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No file provided" });
}
if (!process.env.GROQ_API_KEY) {
return res.status(500).json({ error: "GROQ_API_KEY not configured" });
}
const blob = new Blob([req.file.buffer], { type: req.file.mimetype || "audio/mpeg" });
const formData = new FormData();
formData.append("file", blob, req.file.originalname || "audio.mp3");
formData.append("model", "whisper-large-v3");
formData.append("response_format", "verbose_json");
formData.append("timestamp_granularities[]", "word");
const response = await fetch("https://api.groq.com/openai/v1/audio/transcriptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GROQ_API_KEY}`,
},
body: formData,
});
if (!response.ok) {
const err = await response.text();
console.error("Groq API error:", err);
return res.status(response.status).json({ error: err });
}
const data = await response.json();
// Return both text and word-level timestamps
res.json({
text: data.text,
words: data.words || [], // [{ word, start, end }]
segments: data.segments || [],
});
} catch (error) {
console.error("Transcription error:", error);
res.status(500).json({ error: "Internal server error" });
}
});
// ═══ Stock Media Proxy (Pexels) ═══
const PEXELS_API_KEY = process.env.PEXELS_API_KEY || '';
app.get("/api/stock/photos", async (req, res) => {
if (!PEXELS_API_KEY) {
return res.status(501).json({ error: "PEXELS_API_KEY not configured" });
}
try {
const { q, page = "1", per_page = "20" } = req.query;
const endpoint = q
? `https://api.pexels.com/v1/search?query=${encodeURIComponent(String(q))}&page=${page}&per_page=${per_page}`
: `https://api.pexels.com/v1/curated?page=${page}&per_page=${per_page}`;
const response = await fetch(endpoint, {
headers: { Authorization: PEXELS_API_KEY },
});
const data = await response.json();
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.get("/api/stock/videos", async (req, res) => {
if (!PEXELS_API_KEY) {
return res.status(501).json({ error: "PEXELS_API_KEY not configured" });
}
try {
const { q, page = "1", per_page = "15" } = req.query;
const endpoint = q
? `https://api.pexels.com/videos/search?query=${encodeURIComponent(String(q))}&page=${page}&per_page=${per_page}`
: `https://api.pexels.com/videos/popular?page=${page}&per_page=${per_page}`;
const response = await fetch(endpoint, {
headers: { Authorization: PEXELS_API_KEY },
});
const data = await response.json();
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Download a stock file to uploads/ for persistent use
app.post("/api/stock/download", async (req, res) => {
try {
const { url, filename } = req.body;
if (!url || !filename) {
return res.status(400).json({ error: "url and filename required" });
}
const ext = path.extname(filename) || '.jpg';
const safeFilename = `stock-${crypto.randomUUID()}${ext}`;
const outputPath = path.join(UPLOADS_DIR, safeFilename);
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(outputPath, buffer);
res.json({ url: `/api/media/${safeFilename}` });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ═══ Render Queue ═══
const RENDERS_DIR = path.join(process.cwd(), "renders");
if (!fs.existsSync(RENDERS_DIR)) {
fs.mkdirSync(RENDERS_DIR, { recursive: true });
}
// Serve rendered files
app.use("/api/renders", express.static(RENDERS_DIR, {
maxAge: "1h",
}));
// Lazy-load render queue (heavy dependencies)
let renderQueue: typeof import("./src/server/renderQueue") | null = null;
async function getRenderQueue() {
if (!renderQueue) {
renderQueue = await import("./src/server/renderQueue");
}
return renderQueue;
}
// Start a render job
app.post("/api/render/start", async (req, res) => {
try {
const { format, width, height, fps, durationInFrames, compositionId, inputProps } = req.body;
if (!format || !width || !height || !compositionId) {
return res.status(400).json({ error: "Missing required fields: format, width, height, compositionId" });
}
const rq = await getRenderQueue();
const job = rq.createJob({
format,
width,
height,
fps: fps || 30,
durationInFrames: durationInFrames || 150,
compositionId,
inputProps: inputProps || {},
});
console.log(`🎬 Job created: ${job.id} (${format} ${width}x${height})`);
res.json(job);
} catch (err: any) {
console.error("Render start error:", err);
res.status(500).json({ error: err.message || "Failed to create render job" });
}
});
// List all jobs
app.get("/api/render/jobs", async (_req, res) => {
try {
const rq = await getRenderQueue();
const jobs = rq.getAllJobs();
// Strip inputProps (too large for list)
const sanitized = jobs.map(({ inputProps, ...rest }) => rest);
res.json(sanitized);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Get a single job by ID (used by batch exporter polling)
app.get("/api/render/jobs/:id", async (req, res) => {
try {
const rq = await getRenderQueue();
const job = rq.getJob(req.params.id);
if (!job) {
return res.status(404).json({ error: "Job not found" });
}
// Strip inputProps (too large)
const { inputProps, ...rest } = job;
res.json(rest);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Delete a job
app.delete("/api/render/jobs/:id", async (req, res) => {
try {
const rq = await getRenderQueue();
const deleted = rq.deleteJob(req.params.id);
res.json({ deleted });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// SSE — Real-time job progress
app.get("/api/render/events", async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
// Send initial heartbeat
res.write("data: {\"type\":\"connected\"}\n\n");
const clientId = crypto.randomUUID();
const rq = await getRenderQueue();
const cleanup = rq.addSSEClient(clientId, (data: string) => {
res.write(`data: ${data}\n\n`);
});
// Heartbeat every 30s to keep connection alive
const heartbeat = setInterval(() => {
res.write(": heartbeat\n\n");
}, 30000);
req.on("close", () => {
clearInterval(heartbeat);
cleanup();
});
});
if (process.env.NODE_ENV !== "production") {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "spa",
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), "dist");
app.use(express.static(distPath));
app.get("*", (req, res) => {
res.sendFile(path.join(distPath, "index.html"));
});
}
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server running on http://localhost:${PORT}`);
});
}
startServer();
+386
View File
@@ -0,0 +1,386 @@
import React, { useState, useCallback, useMemo } from 'react';
import { DesignMD, TimelineElement, TimelineLayer, CompanyProfile, Project, ContentPiece, ContentPillar, ExpressTemplate } from './types';
import { TopHeader } from './components/TopHeader';
import { BrandArchitecture } from './components/BrandArchitecture';
import { Dashboard } from './components/Dashboard';
import { ProductionForm } from './components/dashboard/ProductionForm';
import { StudioEditor } from './components/studio/StudioEditor';
import { ExpressEditor } from './components/express/ExpressEditor';
import { StudioTopBar } from './components/studio/StudioTopBar';
import { EditorProvider, useEditor } from './context/EditorContext';
import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults';
import { useCustomTooltips } from './hooks/useCustomTooltips';
import { ToastProvider } from './components/ui/ToastProvider';
import { usePersistence, loadCompanies, useTemplatePersistence, loadTemplates } from './hooks/usePersistence';
import { ContentGridView } from './components/content-grid/ContentGridView';
import { TemplateBuilder } from './components/express/builder/TemplateBuilder';
import { EXPRESS_TEMPLATES } from './config/expressTemplates';
import { compileExpressToTimeline } from './utils/expressCompiler';
import { FullscreenToggle } from './components/ui/FullscreenToggle';
type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
// ── Content persistence ──
const CONTENT_STORAGE_KEY = 'remix-content-data';
function loadContentData(): Record<string, { pieces: ContentPiece[]; pillars: ContentPillar[] }> | null {
try {
const raw = localStorage.getItem(CONTENT_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function saveContentData(data: Record<string, { pieces: ContentPiece[]; pillars: ContentPillar[] }>): void {
try { localStorage.setItem(CONTENT_STORAGE_KEY, JSON.stringify(data)); } catch {}
}
export default function App() {
const [companies, setCompanies] = useState<CompanyProfile[]>(() => {
return loadCompanies() ?? PREDEFINED_COMPANIES;
});
const [currentCompanyId, setCurrentCompanyId] = useState<string | null>(null);
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState<Step>('dashboard');
const [designMD, setDesignMD] = useState<DesignMD>(DEFAULT_DESIGN_MD);
const [outputFormat, setOutputFormat] = useState<'video' | 'image'>('video');
// Global templates (decoupled from brands) — persisted
const [globalTemplates, setGlobalTemplates] = useState<ExpressTemplate[]>(() => {
return loadTemplates() ?? [];
});
const [templateBuilderFormat, setTemplateBuilderFormat] = useState<'video' | 'image'>('image');
const [templateBuilderAspect, setTemplateBuilderAspect] = useState<ExpressTemplate['aspectRatio']>('9:16');
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState<ExpressTemplate | null>(null);
// Production form state
const [productionTemplate, setProductionTemplate] = useState<ExpressTemplate | null>(null);
const [productionBrand, setProductionBrand] = useState<CompanyProfile | null>(null);
// Merge preset + custom templates for the dashboard
const allTemplates = useMemo(() => [
...EXPRESS_TEMPLATES,
...globalTemplates,
], [globalTemplates]);
const handleSaveGlobalTemplate = useCallback((template: ExpressTemplate) => {
setGlobalTemplates(prev => {
const existing = prev.findIndex(t => t.id === template.id);
if (existing >= 0) {
const next = [...prev];
next[existing] = template;
return next;
}
return [...prev, template];
});
setEditingGlobalTemplate(null);
setCurrentStep('dashboard');
}, []);
// Content grid state (per company)
const [contentData, setContentData] = useState<Record<string, { pieces: ContentPiece[]; pillars: ContentPillar[] }>>(() => {
return loadContentData() ?? {};
});
const getContentForCompany = useCallback((companyId: string) => {
return contentData[companyId] ?? { pieces: [], pillars: [...DEFAULT_PILLARS] };
}, [contentData]);
const updateContentPieces = useCallback((companyId: string, pieces: ContentPiece[]) => {
setContentData(prev => {
const next = { ...prev, [companyId]: { ...prev[companyId] ?? { pillars: [...DEFAULT_PILLARS] }, pieces } };
saveContentData(next);
return next;
});
}, []);
const updateContentPillars = useCallback((companyId: string, pillars: ContentPillar[]) => {
setContentData(prev => {
const next = { ...prev, [companyId]: { ...prev[companyId] ?? { pieces: [] }, pillars } };
saveContentData(next);
return next;
});
}, []);
// Studio initial data (passed to EditorProvider when entering studio)
const [studioInitialElements, setStudioInitialElements] = useState<TimelineElement[]>([]);
const [studioInitialLayers, setStudioInitialLayers] = useState<TimelineLayer[]>([
{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' }
]);
// Key to force remount EditorProvider when switching projects
const [editorKey, setEditorKey] = useState(0);
useCustomTooltips();
usePersistence(companies);
useTemplatePersistence(globalTemplates);
const handleDesignChange = (key: keyof DesignMD, value: string | number | string[] | boolean) => {
setDesignMD((prev) => {
const newDesign = { ...prev, [key]: value };
if (currentCompanyId) {
setCompanies(prev2 => prev2.map(c => c.id === currentCompanyId ? { ...c, design: newDesign } : c));
}
return newDesign;
});
};
const saveCurrentProject = (elements: TimelineElement[], layers: TimelineLayer[]) => {
if (currentCompanyId) {
setCompanies(prev => prev.map(c => {
if (c.id !== currentCompanyId) return c;
const projs = c.projects || [];
if (currentProjectId) {
return {
...c,
projects: projs.map(p => p.id === currentProjectId ? { ...p, elements, layers } : p)
};
} else {
const newId = `proj-${Date.now()}`;
const newProject: Project = {
id: newId,
name: `Proyecto ${outputFormat === 'video' ? 'Video' : 'Imagen'} ${projs.length + 1}`,
format: outputFormat,
elements,
layers
};
setCurrentProjectId(newId);
return { ...c, projects: [...projs, newProject] };
}
}));
}
};
const enterStudio = (design: DesignMD, format: 'video' | 'image', elements: TimelineElement[], layers: TimelineLayer[], companyId?: string, projectId?: string | null) => {
if (companyId) setCurrentCompanyId(companyId);
if (projectId !== undefined) setCurrentProjectId(projectId);
setDesignMD(design);
setOutputFormat(format);
setStudioInitialElements(elements);
setStudioInitialLayers(layers);
setEditorKey(prev => prev + 1);
setCurrentStep('studio');
};
// ── Blank canvas editors (no brand) ──
const handleStartExpressBlank = useCallback(() => {
setCurrentCompanyId(null);
setDesignMD(DEFAULT_DESIGN_MD);
setOutputFormat('video');
setCurrentStep('express');
}, []);
const handleStartProBlank = useCallback(() => {
const initialElements: TimelineElement[] = [{
id: `el-content-${Date.now()}`,
layerId: 'layer-1',
type: 'text',
content: 'Inserta tu contenido aquí',
startFrame: 0,
endFrame: 180,
x: 50, y: 50,
fontSize: 48,
color: '#FFFFFF',
fontFamily: DEFAULT_DESIGN_MD.baseFont,
}];
const initialLayers: TimelineLayer[] = [{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' }];
enterStudio(DEFAULT_DESIGN_MD, 'video', initialElements, initialLayers, undefined, null);
}, []);
// ── Production flow: template × brand → form → editor ──
const handleGenerate = useCallback((template: ExpressTemplate, brand: CompanyProfile) => {
setProductionTemplate(template);
setProductionBrand(brand);
setCurrentStep('production-form');
}, []);
// ── Template management (edit / duplicate / delete) ──
const handleEditTemplate = useCallback((template: ExpressTemplate) => {
setEditingGlobalTemplate(template);
setTemplateBuilderFormat(template.format);
setCurrentStep('template-builder');
}, []);
const handleDuplicateTemplate = useCallback((template: ExpressTemplate) => {
const copy: ExpressTemplate = {
...template,
id: `tpl-${Date.now()}`,
name: `${template.name} (Copia)`,
isCustom: true,
createdAt: new Date().toISOString(),
scenes: template.scenes.map(s => ({ ...s, id: `scene-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` })),
};
setGlobalTemplates(prev => [...prev, copy]);
}, []);
const handleDeleteTemplate = useCallback((id: string) => {
setGlobalTemplates(prev => prev.filter(t => t.id !== id));
}, []);
const handleProducePro = useCallback((fieldData: Record<string, string>) => {
if (!productionTemplate || !productionBrand) return;
// Compile template + brand + fieldData → TimelineElement[]
const compiled = compileExpressToTimeline(productionTemplate, fieldData, productionBrand.design, productionBrand);
enterStudio(productionBrand.design, productionTemplate.format, compiled.elements, compiled.layers, productionBrand.id, null);
}, [productionTemplate, productionBrand]);
return (
<ToastProvider>
<div className="flex flex-col h-screen bg-neutral-950 text-neutral-100 font-sans overflow-hidden">
{currentStep !== 'studio' && (
<TopHeader
currentStep={currentStep}
setCurrentStep={(step) => {
setCurrentStep(step);
}}
outputFormat={outputFormat}
onStartExpressBlank={handleStartExpressBlank}
onStartProBlank={handleStartProBlank}
/>
)}
<div className="flex-1 flex overflow-hidden relative bg-neutral-950">
{currentStep === 'dashboard' && (
<Dashboard
companies={companies}
templates={allTemplates}
onCreateBrand={(name, industry) => {
const newAppId = Date.now().toString();
const newBrand: CompanyProfile = {
id: newAppId,
name,
industry,
design: { ...DEFAULT_DESIGN_MD },
projects: []
};
setCompanies(prev => [...prev, newBrand]);
setCurrentCompanyId(newAppId);
setDesignMD(newBrand.design);
setCurrentStep('brand');
}}
onDeleteBrand={(id) => {
setCompanies(prev => prev.filter(c => c.id !== id));
}}
onDuplicateBrand={(id) => {
const original = companies.find(c => c.id === id);
if (!original) return;
const newId = Date.now().toString();
const duplicate: CompanyProfile = {
...original,
id: newId,
name: `${original.name} (Copia)`,
projects: [],
design: { ...original.design, brandStickers: [...(original.design.brandStickers || [])] },
socialLinks: original.socialLinks ? { ...original.socialLinks } : undefined,
};
setCompanies(prev => [...prev, duplicate]);
}}
onEditBrand={(design) => {
const comp = companies.find(c => c.design === design);
if (comp) setCurrentCompanyId(comp.id);
setDesignMD(design);
setCurrentStep('brand');
}}
onOpenContentGrid={(companyId) => {
setCurrentCompanyId(companyId);
setCurrentStep('content-grid');
}}
onCreateTemplate={(format, aspect) => {
setTemplateBuilderFormat(format);
setTemplateBuilderAspect(aspect);
setEditingGlobalTemplate(null);
setCurrentStep('template-builder');
}}
onEditTemplate={handleEditTemplate}
onDuplicateTemplate={handleDuplicateTemplate}
onDeleteTemplate={handleDeleteTemplate}
onGenerate={handleGenerate}
/>
)}
{currentStep === 'production-form' && productionTemplate && productionBrand && (
<ProductionForm
template={productionTemplate}
brand={productionBrand}
onBack={() => setCurrentStep('dashboard')}
onProducePro={handleProducePro}
/>
)}
{currentStep === 'brand' && (
<BrandArchitecture
company={companies.find(c => c.id === currentCompanyId)!}
handleCompanyChange={(company) => {
setCompanies(prev => prev.map(c => c.id === company.id ? company : c));
}}
designMD={designMD}
handleDesignChange={handleDesignChange}
onContinue={() => setCurrentStep('dashboard')}
/>
)}
{currentStep === 'content-grid' && currentCompanyId && (
<ContentGridView
company={companies.find(c => c.id === currentCompanyId)!}
pieces={getContentForCompany(currentCompanyId).pieces}
pillars={getContentForCompany(currentCompanyId).pillars}
onPiecesChange={(pieces) => updateContentPieces(currentCompanyId, pieces)}
onPillarsChange={(pillars) => updateContentPillars(currentCompanyId, pillars)}
onOpenProject={(projectId) => {
const comp = companies.find(c => c.id === currentCompanyId);
if (comp) {
const proj = comp.projects.find(p => p.id === projectId);
if (proj) {
const layers = proj.layers.length > 0 ? proj.layers : [{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' as const }];
enterStudio(comp.design, proj.format, proj.elements, layers, comp.id, projectId);
}
}
}}
/>
)}
{currentStep === 'express' && (
<ExpressEditor
designMD={designMD}
company={companies.find(c => c.id === currentCompanyId)}
onBack={() => setCurrentStep('dashboard')}
onUpgradeToPro={(elements, layers) => {
const comp = companies.find(c => c.id === currentCompanyId);
enterStudio(designMD, outputFormat, elements, layers, comp?.id, null);
}}
onExport={(elements, layers, format) => {
const comp = companies.find(c => c.id === currentCompanyId);
enterStudio(designMD, format, elements, layers, comp?.id, null);
}}
/>
)}
{currentStep === 'studio' && (
<EditorProvider
key={editorKey}
initialDesignMD={designMD}
initialElements={studioInitialElements}
initialLayers={studioInitialLayers}
initialFormat={outputFormat}
brandContent={companies.find(c => c.id === currentCompanyId)?.brandContent}
>
<div className="flex-1 flex flex-col overflow-hidden">
<StudioTopBar setCurrentStep={setCurrentStep} />
<StudioEditor />
</div>
</EditorProvider>
)}
{currentStep === 'template-builder' && (
<TemplateBuilder
availableBrands={companies}
onSave={handleSaveGlobalTemplate}
onBack={() => setCurrentStep('dashboard')}
editingTemplate={editingGlobalTemplate}
initialFormat={templateBuilderFormat}
initialAspect={templateBuilderAspect}
/>
)}
</div>
<FullscreenToggle />
</div>
</ToastProvider>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Remotion Root — Entry point for bundler to discover compositions.
*
* This file is referenced by the server-side renderer bundle step.
* It registers BrandComposition as a renderable Composition.
*/
import React from 'react';
import { Composition, Still, registerRoot } from 'remotion';
import { BrandComposition } from './components/BrandComposition';
import { RenderProps } from './types';
export const RemotionRoot: React.FC = () => {
return (
<>
{/* Video composition — used for MP4/WebM rendering */}
<Composition
id="BrandVideo"
component={BrandComposition}
durationInFrames={150}
fps={30}
width={1080}
height={1080}
defaultProps={{
designMD: {} as any,
textOverlay: '',
timelineElements: [],
layers: [],
}}
/>
{/* Still composition — used for PNG/JPEG rendering */}
<Still
id="BrandStill"
component={BrandComposition}
width={1080}
height={1080}
defaultProps={{
designMD: {} as any,
textOverlay: '',
timelineElements: [],
layers: [],
}}
/>
</>
);
};
registerRoot(RemotionRoot);
+229
View File
@@ -0,0 +1,229 @@
import React, { useState, useCallback } from 'react';
import { Save, AlertCircle, Crown } from 'lucide-react';
import { DesignMD, CompanyProfile } from '../types';
import { BrandTabGeneral } from './brand/BrandTabGeneral';
import { BrandTabVisual } from './brand/BrandTabVisual';
import { BrandTabTypography } from './brand/BrandTabTypography';
import { BrandTabMedia } from './brand/BrandTabMedia';
import { BrandPreview } from './brand/BrandPreview';
import { Toast } from './ui/Toast';
interface BrandArchitectureProps {
company: CompanyProfile;
handleCompanyChange: (company: CompanyProfile) => void;
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
onContinue: () => void;
}
const TABS = [
{ id: 'general', label: 'Información', icon: '📋' },
{ id: 'visual', label: 'Visual y Colores', icon: '🎨' },
{ id: 'typography', label: 'Tipografía', icon: '🔤' },
{ id: 'media', label: 'Video y Audio', icon: '🎬' },
] as const;
type TabId = typeof TABS[number]['id'];
export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, handleCompanyChange, designMD, handleDesignChange, onContinue }) => {
const [zoom, setZoom] = useState(1);
const [aspectRatio, setAspectRatio] = useState<'16:9'|'1:1'|'9:16'>('9:16');
const [activeTab, setActiveTab] = useState<TabId>('general');
const [showToast, setShowToast] = useState(false);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const validate = useCallback((): string[] => {
const errors: string[] = [];
if (!company?.name || company.name.trim().length < 2) {
errors.push('El nombre de la marca es requerido (mín. 2 caracteres)');
}
if (!designMD.logoUrl || designMD.logoUrl.trim().length === 0) {
errors.push('El logo de la marca es requerido');
}
return errors;
}, [company, designMD]);
const handleSave = () => {
const errors = validate();
if (errors.length > 0) {
setValidationErrors(errors);
setTimeout(() => setValidationErrors([]), 5000);
return;
}
setValidationErrors([]);
setShowToast(true);
setTimeout(() => {
onContinue();
}, 800);
};
return (
<div className="flex-1 flex flex-col w-full overflow-hidden">
{/* ═══ Sticky Header: Title + Brand Identity ═══ */}
<div className="shrink-0 sticky top-0 z-20 bg-neutral-950/95 backdrop-blur-md border-b border-neutral-800/60">
<div className="px-8 pt-6 pb-4">
<div className="flex items-start justify-between gap-6">
{/* Left: Title + Description */}
<div className="min-w-0">
<h2 className="text-xl font-bold text-white tracking-tight">Reglas de tu Marca (Design MD)</h2>
<p className="text-sm text-neutral-400 leading-relaxed mt-1">
Establece el plano arquitectónico visual de la empresa. Todos los videos y renders
futuros adoptarán estrictamente estos parámetros sin intervención de IA.
</p>
</div>
{/* Right: Brand Identity Card + Save */}
<div className="shrink-0 flex items-center gap-3">
{/* Brand Identity Card */}
<div className="flex items-center gap-3 bg-neutral-900/80 border border-neutral-800 rounded-xl px-4 py-2.5 backdrop-blur-sm">
{/* Logo / Avatar */}
<div className="w-9 h-9 rounded-lg overflow-hidden bg-neutral-800 border border-neutral-700 flex items-center justify-center shrink-0">
{designMD.logoUrl ? (
<img
src={designMD.logoUrl}
alt={company.name}
className="w-full h-full object-contain"
/>
) : (
<span className="text-lg font-bold text-neutral-500">
{company.name?.charAt(0)?.toUpperCase() || '?'}
</span>
)}
</div>
{/* Name + Plan */}
<div className="min-w-0">
<p className="text-sm font-semibold text-white truncate max-w-[140px]">
{company.name || 'Sin nombre'}
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Crown size={10} className="text-amber-400 shrink-0" />
<span className="text-[10px] font-medium text-amber-400/80 tracking-wide uppercase">
{company.industry || 'Marca'}
</span>
</div>
</div>
{/* Brand color dot indicator */}
<div className="flex flex-col gap-1 ml-2">
<div
className="w-3 h-3 rounded-full border border-white/10 shadow-sm"
style={{ backgroundColor: designMD.primaryColor }}
title={`Primario: ${designMD.primaryColor}`}
/>
<div
className="w-3 h-3 rounded-full border border-white/10 shadow-sm"
style={{ backgroundColor: designMD.secondaryColor }}
title={`Secundario: ${designMD.secondaryColor}`}
/>
</div>
</div>
{/* Save Button */}
<button
onClick={handleSave}
title="Guardar marca"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white text-sm font-semibold transition-all shadow-lg shadow-emerald-900/30"
>
<Save size={14} />
Guardar
</button>
</div>
</div>
</div>
{/* ═══ Full-Width Tabbar ═══ */}
<div className="px-8 pb-0">
<div className="flex bg-neutral-900/60 border border-neutral-800 rounded-t-xl overflow-hidden">
{TABS.map((tab, idx) => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-3 text-[13px] font-medium transition-all relative ${
isActive
? 'bg-neutral-800/80 text-white'
: 'text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800/30'
} ${idx > 0 ? 'border-l border-neutral-800/50' : ''}`}
>
<span className="text-sm">{tab.icon}</span>
<span className="hidden xl:inline">{tab.label}</span>
<span className="xl:hidden text-xs">{tab.label.split(' ')[0]}</span>
{/* Active indicator bar */}
{isActive && (
<div className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500" />
)}
</button>
);
})}
</div>
</div>
</div>
{/* ═══ Main Content: Form + Preview Split ═══ */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Form Column */}
<div className="w-1/2 overflow-y-auto border-r border-neutral-800 bg-neutral-950/80 backdrop-blur-sm">
<div className="max-w-xl mx-auto p-8 space-y-6">
{/* Validation Errors */}
{validationErrors.length > 0 && (
<div className="bg-rose-950/30 border border-rose-800/50 rounded-xl p-4 space-y-1.5">
{validationErrors.map((err, i) => (
<p key={i} className="text-sm text-rose-300 flex items-center gap-2">
<AlertCircle size={14} className="shrink-0" />
{err}
</p>
))}
</div>
)}
{/* Tab Content */}
{activeTab === 'general' && (
<BrandTabGeneral company={company} handleCompanyChange={handleCompanyChange} />
)}
{activeTab === 'visual' && (
<BrandTabVisual
designMD={designMD}
handleDesignChange={handleDesignChange}
/>
)}
{activeTab === 'typography' && (
<BrandTabTypography designMD={designMD} handleDesignChange={handleDesignChange} />
)}
{activeTab === 'media' && (
<BrandTabMedia
designMD={designMD}
handleDesignChange={handleDesignChange}
/>
)}
</div>
</div>
{/* Preview Column */}
<BrandPreview
designMD={designMD}
company={company}
activeTab={activeTab}
zoom={zoom}
setZoom={setZoom}
aspectRatio={aspectRatio}
setAspectRatio={setAspectRatio}
handleDesignChange={handleDesignChange}
/>
</div>
{/* Success Toast */}
{showToast && (
<Toast
message="Marca guardada exitosamente ✓"
type="success"
onDismiss={() => setShowToast(false)}
/>
)}
</div>
);
};
+112
View File
@@ -0,0 +1,112 @@
import React from 'react';
import { AbsoluteFill, useCurrentFrame } from 'remotion';
import { RenderProps } from '../types';
import { useCanvasDrag } from './composition/useCanvasDrag';
import { BackgroundLayer } from './composition/BackgroundLayer';
import { BrandOverlay } from './composition/BrandOverlay';
import { CompositionElement } from './composition/CompositionElement';
import { SmartGuides } from './composition/SmartGuides';
export const BrandComposition: React.FC<RenderProps> = ({
designMD,
textOverlay,
timelineElements = [],
layers = [],
onElementClick,
onElementPositionChange,
onElementContextMenu,
onElementDoubleClick,
onElementTransformChange,
onElementDuplicate,
onElementDelete,
onElementLock,
selectedElementId,
activeLayerId,
activeAction,
brandVisibility,
outputFormat
}) => {
const frame = useCurrentFrame();
const {
containerRef,
dragState,
setDragState,
transformDragState,
setTransformDragState,
tempPositions,
guides
} = useCanvasDrag(timelineElements, onElementPositionChange, onElementTransformChange);
// Separate brand fullscreen videos from other elements for correct z-order
const brandFullscreenEls = timelineElements.filter(el =>
el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video'
);
const otherElements = timelineElements.filter(el =>
!(el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video')
);
const renderElement = (el: typeof timelineElements[0]) => {
let layer = layers.find(l => l.id === el.layerId);
// Solo-aware mute: if any audio layer has isSolo, mute all other audio layers
if (layer?.type === 'audio') {
const anySoloActive = layers.some(l => l.type === 'audio' && l.isSolo);
if (anySoloActive && !layer.isSolo) {
layer = { ...layer, isMuted: true };
}
}
return (
<CompositionElement
key={el.id}
element={el}
layer={layer}
designMD={designMD}
frame={frame}
selectedElementId={selectedElementId ?? null}
activeLayerId={activeLayerId ?? null}
activeAction={activeAction ?? 'move'}
isImageMode={outputFormat === 'image'}
tempPositions={tempPositions}
dragStateId={dragState?.id ?? null}
containerRef={containerRef}
onElementClick={onElementClick}
onElementDoubleClick={onElementDoubleClick}
onElementContextMenu={onElementContextMenu}
onElementDuplicate={onElementDuplicate}
onElementDelete={onElementDelete}
onElementLock={onElementLock}
onDragStart={(id, startX, startY, initialElX, initialElY) => {
if (onElementPositionChange) {
setDragState({ id, startX, startY, initialElX, initialElY });
}
}}
onTransformStart={(id, type, startX, startY, initialScale, initialRot, centerX, centerY) => {
setTransformDragState({ id, type, startX, startY, initialScale, initialRot, centerX, centerY });
}}
/>
);
};
const showBackground = brandVisibility?.background ?? true;
return (
<AbsoluteFill style={{ backgroundColor: showBackground ? designMD.secondaryColor : 'transparent' }} ref={containerRef}>
{/* Layer 1: Background media (user-uploaded backgrounds) */}
<BackgroundLayer timelineElements={timelineElements} layers={layers} />
{/* Layer 2: Brand fullscreen videos (intro/outro) — BELOW logo/frame */}
{brandFullscreenEls.map(renderElement)}
{/* Layer 3: Brand Overlay (logo + frame) */}
<BrandOverlay designMD={designMD} textOverlay={textOverlay} brandVisibility={brandVisibility} />
{/* Layer 4: All other elements (text, images, non-fullscreen brand, etc.) */}
{otherElements.map(renderElement)}
{/* Smart Guides Overlay */}
<SmartGuides guides={guides} />
</AbsoluteFill>
);
};
+197
View File
@@ -0,0 +1,197 @@
import React, { useState, useCallback } from 'react';
import {
DndContext,
DragOverlay,
DragStartEvent,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { Sparkles } from 'lucide-react';
import { DesignMD, CompanyProfile, ExpressTemplate } from '../types';
import { TemplatesPanel, TemplateDragPreview } from './dashboard/TemplatesPanel';
import { BrandsPanel, BrandDragPreview } from './dashboard/BrandsPanel';
import { GenerateZone } from './dashboard/GenerateZone';
import { CreateBrandModal } from './brand/CreateBrandModal';
interface DashboardProps {
companies: CompanyProfile[];
templates: ExpressTemplate[];
onCreateBrand: (name: string, industry?: string) => void;
onDeleteBrand: (id: string) => void;
onDuplicateBrand: (id: string) => void;
onEditBrand: (design: DesignMD) => void;
onOpenContentGrid: (companyId: string) => void;
onCreateTemplate: (format: 'video' | 'image', aspect: ExpressTemplate['aspectRatio']) => void;
onEditTemplate: (template: ExpressTemplate) => void;
onDuplicateTemplate: (template: ExpressTemplate) => void;
onDeleteTemplate: (id: string) => void;
onGenerate: (template: ExpressTemplate, brand: CompanyProfile) => void;
}
type DragItem =
| { type: 'template'; template: ExpressTemplate }
| { type: 'brand'; company: CompanyProfile };
/**
* Dashboard — Redesigned around "content = template × brand".
*
* Three zones:
* 1. TemplatesPanel (top-left) — draggable template grid with search
* 2. BrandsPanel (top-right) — draggable brand folder grid with search
* 3. GenerateZone (bottom, full-width) — drop slots + Generate button
*/
export const Dashboard: React.FC<DashboardProps> = ({
companies,
templates,
onCreateBrand,
onDeleteBrand,
onDuplicateBrand,
onEditBrand,
onOpenContentGrid,
onCreateTemplate,
onEditTemplate,
onDuplicateTemplate,
onDeleteTemplate,
onGenerate,
}) => {
const [selectedTemplate, setSelectedTemplate] = useState<ExpressTemplate | null>(null);
const [selectedBrand, setSelectedBrand] = useState<CompanyProfile | null>(null);
const [activeDrag, setActiveDrag] = useState<DragItem | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
// DnD sensor config — require 5px movement before starting drag (allows click)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as DragItem | undefined;
if (data) setActiveDrag(data);
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
setActiveDrag(null);
const { active, over } = event;
if (!over) return;
const data = active.data.current as DragItem | undefined;
if (!data) return;
const droppedOnSlot = over.id as string;
if (data.type === 'template' && droppedOnSlot === 'slot-template') {
setSelectedTemplate(data.template);
} else if (data.type === 'brand' && droppedOnSlot === 'slot-brand') {
setSelectedBrand(data.company);
}
// If user drops template on brand slot or vice versa, ignore silently
}, []);
const handleDragCancel = useCallback(() => {
setActiveDrag(null);
}, []);
// Click-based selection (alternative to drag)
const handleSelectTemplate = useCallback((t: ExpressTemplate) => {
setSelectedTemplate(t);
}, []);
const handleSelectBrand = useCallback((c: CompanyProfile) => {
setSelectedBrand(c);
}, []);
const handleGenerate = useCallback(() => {
if (selectedTemplate && selectedBrand) {
onGenerate(selectedTemplate, selectedBrand);
}
}, [selectedTemplate, selectedBrand, onGenerate]);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex-1 overflow-y-auto w-full relative bg-neutral-950">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
/>
<div className="max-w-6xl w-full mx-auto p-8 relative z-10">
{/* ── Header ── */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-violet-900/30">
<Sparkles size={20} className="text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Crear Contenido</h1>
<p className="text-sm text-neutral-500">Combina una plantilla con una marca para generar contenido</p>
</div>
</div>
</div>
{/* ── Zone 1 & 2: Templates + Brands (side by side) ── */}
<div className="flex gap-5 mb-6" style={{ height: 380 }}>
<TemplatesPanel
templates={templates}
onSelect={handleSelectTemplate}
onCreateTemplate={onCreateTemplate}
onEditTemplate={onEditTemplate}
onDuplicateTemplate={onDuplicateTemplate}
onDeleteTemplate={onDeleteTemplate}
/>
<BrandsPanel
companies={companies}
onSelect={handleSelectBrand}
onCreateBrand={() => setShowCreateModal(true)}
onEditBrand={(c) => onEditBrand(c.design)}
onDeleteBrand={onDeleteBrand}
onDuplicateBrand={onDuplicateBrand}
onOpenContentGrid={onOpenContentGrid}
/>
</div>
{/* ── Zone 3: Generate Content ── */}
<GenerateZone
selectedTemplate={selectedTemplate}
selectedBrand={selectedBrand}
onClearTemplate={() => setSelectedTemplate(null)}
onClearBrand={() => setSelectedBrand(null)}
onClickTemplateSlot={() => {/* Could open a modal selector — for now click on panel */}}
onClickBrandSlot={() => {/* Could open a modal selector — for now click on panel */}}
onGenerate={handleGenerate}
/>
</div>
</div>
{/* Drag Overlay — shows a floating preview while dragging */}
<DragOverlay dropAnimation={null}>
{activeDrag?.type === 'template' && (
<TemplateDragPreview template={activeDrag.template} />
)}
{activeDrag?.type === 'brand' && (
<BrandDragPreview company={activeDrag.company} />
)}
</DragOverlay>
{/* Create Brand Modal */}
{showCreateModal && (
<CreateBrandModal
onConfirm={(name, industry) => {
onCreateBrand(name, industry);
setShowCreateModal(false);
}}
onCancel={() => setShowCreateModal(false)}
/>
)}
</DndContext>
);
};
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
interface DeleteConfirmModalProps {
onConfirm: () => void;
onCancel: () => void;
}
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({ onConfirm, onCancel }) => {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-neutral-900 border border-neutral-700 p-6 rounded-xl shadow-2xl max-w-sm w-full mx-4">
<h3 className="text-lg font-bold text-white mb-2">Eliminar Objeto</h3>
<p className="text-neutral-400 text-sm mb-6">¿Estás seguro de que deseas eliminar este elemento? Esta acción no se puede deshacer.</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-neutral-300 hover:text-white hover:bg-neutral-800 transition-colors text-sm font-medium"
>
Cancelar
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-600/90 hover:bg-red-500 text-white rounded-lg transition-colors text-sm font-medium shadow-lg shadow-red-900/20"
>
, eliminar
</button>
</div>
</div>
</div>
);
};
+159
View File
@@ -0,0 +1,159 @@
import React, { useState, useCallback } from 'react';
import { DesignMD, BrandContentPiece } from '../types';
import { X, Search, Image as ImageIcon, Video, Film, Loader2 } from 'lucide-react';
import { uploadMedia } from '../utils/mediaUploader';
import { FileDropZone } from './ui/FileDropZone';
import { StockMediaTab } from './panels/StockMediaTab';
const mockImages: string[] = [];
const mockVideos: string[] = [];
interface MediaLibraryPanelProps {
onClose: () => void;
designMD: DesignMD;
brandContent?: BrandContentPiece[];
}
type MediaTab = 'images' | 'video' | 'stock';
type LocalFile = { type: MediaTab; src: string };
export const MediaLibraryPanel: React.FC<MediaLibraryPanelProps> = ({ onClose, designMD, brandContent = [] }) => {
const [tab, setTab] = useState<MediaTab>('images');
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
const [isUploading, setIsUploading] = useState(false);
const handleFileUpload = useCallback(async (files: File[]) => {
setIsUploading(true);
try {
const newFiles: LocalFile[] = [];
for (const file of files) {
let type: MediaTab = 'images';
if (file.type.startsWith('video/')) type = 'video';
const result = await uploadMedia(file);
newFiles.push({ type, src: result.url });
}
setLocalFiles(prev => [...newFiles, ...prev]);
if (newFiles.length > 0) {
setTab(newFiles[0].type);
}
} catch (err) {
console.error('Upload failed:', err);
} finally {
setIsUploading(false);
}
}, []);
const currentItems = tab === 'images' ? mockImages : mockVideos;
const filteredLocalFiles = localFiles.filter(f => f.type === tab).map(f => f.src);
const displayItems = [...filteredLocalFiles, ...currentItems];
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Film size={14} className="text-sky-400" />
Media
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
{/* Tabs: Fotos | Video */}
<div className="flex border-b border-neutral-800">
<button
onClick={() => setTab('images')}
title="Ver Imágenes"
className={`flex-1 p-2.5 text-xs font-medium flex justify-center items-center gap-1.5 ${tab === 'images' ? 'text-violet-400 border-b-2 border-violet-400' : 'text-neutral-400 hover:text-neutral-200'}`}
>
<ImageIcon size={14} /> Fotos
</button>
<button
onClick={() => setTab('video')}
title="Ver Videos"
className={`flex-1 p-2.5 text-xs font-medium flex justify-center items-center gap-1.5 ${tab === 'video' ? 'text-violet-400 border-b-2 border-violet-400' : 'text-neutral-400 hover:text-neutral-200'}`}
>
<Video size={14} /> Video
</button>
<button
onClick={() => setTab('stock')}
title="Buscar en Pexels"
className={`flex-1 p-2.5 text-xs font-medium flex justify-center items-center gap-1.5 ${tab === 'stock' ? 'text-violet-400 border-b-2 border-violet-400' : 'text-neutral-400 hover:text-neutral-200'}`}
>
<Search size={14} /> Stock
</button>
</div>
{/* Stock Media Tab */}
{tab === 'stock' ? (
<StockMediaTab />
) : (
<div className="p-3 flex-1 overflow-y-auto space-y-3">
{/* Search */}
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
placeholder={`Buscar ${tab === 'images' ? 'fotos' : 'videos'}...`}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-violet-500"
/>
</div>
{/* Upload */}
<FileDropZone
accept={tab === 'images' ? 'image/*' : 'video/*'}
multiple
onFiles={handleFileUpload}
label={isUploading ? 'Subiendo...' : `Subir ${tab === 'images' ? 'imágenes' : 'videos'}`}
sublabel={isUploading ? undefined : "o arrastra archivos aquí"}
/>
{isUploading && (
<div className="flex items-center justify-center gap-2 py-2 text-violet-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-[10px] font-medium">Subiendo al servidor...</span>
</div>
)}
{/* Grid */}
<div className="grid grid-cols-2 gap-2">
{displayItems.map((src, i) => (
<div
key={`${tab}-${i}`}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-1"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: tab, src }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
{tab === 'images' ? (
<img src={src} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 rounded" alt="Media" draggable={false} />
) : (
<video src={src} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 rounded" />
)}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none rounded-lg">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
</div>
</div>
))}
</div>
{/* Empty state */}
{displayItems.length === 0 && (
<div className="text-center py-6 text-neutral-500">
{tab === 'images' ? <ImageIcon size={28} className="mx-auto mb-2 opacity-40" /> : <Video size={28} className="mx-auto mb-2 opacity-40" />}
<p className="text-xs font-medium">Sin {tab === 'images' ? 'imágenes' : 'videos'}</p>
<p className="text-[10px] mt-1">Sube archivos para empezar</p>
</div>
)}
</div>
)}
</div>
);
};
+152
View File
@@ -0,0 +1,152 @@
import React, { RefObject } from 'react';
import { PlayerRef } from '@remotion/player';
import { Type, Image as ImageIcon, Trash2, Film, Upload, Wand2, Play, ImagePlus, Square, Plus } from 'lucide-react';
import { TimelineElement, MediaFilter, TimelineLayer, DesignMD } from '../types';
import { AudioLayerPanel } from './properties/AudioLayerPanel';
import { GraphicLayerPanel } from './properties/GraphicLayerPanel';
import { TransitionsPanel } from './properties/TransitionsPanel';
import { GlobalSettingsPanel } from './properties/GlobalSettingsPanel';
import { ElementPropertiesPanel } from './properties/ElementPropertiesPanel';
import { ImageLayersPanel } from './properties/ImageLayersPanel';
import { MultiSelectActions } from './properties/MultiSelectActions';
import { uploadMedia } from '../utils/mediaUploader';
interface StudioPropertiesProps {
selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void;
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
layers: TimelineLayer[];
timeUnit: 'frames' | 'seconds';
textOverlay: string;
setTextOverlay: (text: string) => void;
activeLayerId: string;
playerRef: RefObject<PlayerRef | null>;
activeTool: 'select' | 'text' | 'sticker' | 'transitions' | 'media';
designMD: DesignMD;
outputFormat?: 'video' | 'image';
onExportClick?: () => void;
onShowRenderHistory?: () => void;
showGrid?: boolean;
setShowGrid?: (show: boolean) => void;
showSafeZone?: boolean;
setShowSafeZone?: (show: boolean) => void;
selectedElementIds?: Set<string>;
clearSelection?: () => void;
}
export const StudioProperties: React.FC<StudioPropertiesProps> = ({
selectedElementId,
setSelectedElementId,
timelineElements,
setTimelineElements,
layers,
timeUnit,
textOverlay,
setTextOverlay,
activeLayerId,
playerRef,
activeTool,
designMD,
outputFormat,
onExportClick,
onShowRenderHistory,
showGrid,
setShowGrid,
showSafeZone,
setShowSafeZone,
selectedElementIds,
clearSelection,
}) => {
const selectedElementIndex = timelineElements.findIndex(s => s.id === selectedElementId);
const selectedElement = selectedElementIndex !== -1 ? timelineElements[selectedElementIndex] : null;
const selectedElementLayer = selectedElement ? layers.find(l => l.id === selectedElement.layerId) : null;
const isBackgroundElement = selectedElementLayer?.type === 'background';
const backgroundElements = timelineElements.filter(el => layers.find(l => l.id === el.layerId)?.type === 'background');
const handleFileUploadBg = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedElement || !isBackgroundElement) return;
const file = e.target.files?.[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const type = isVideo ? 'video' : 'image';
try {
const result = await uploadMedia(file);
const newElements = [...timelineElements];
newElements[selectedElementIndex] = { ...newElements[selectedElementIndex], type, content: result.url };
setTimelineElements(newElements);
} catch (err) {
console.error('Background upload failed:', err);
}
};
const isImageMode = outputFormat === 'image';
return (
<aside className="w-72 bg-neutral-900 border-l border-neutral-800/60 flex flex-col z-10 shrink-0" onClick={(e) => e.stopPropagation()}>
{/* Properties section */}
<div className={isImageMode ? 'shrink-0 border-b border-neutral-800 overflow-y-auto max-h-[50%]' : 'flex-1 overflow-y-auto'}>
{activeTool === 'transitions' ? (
<TransitionsPanel designMD={designMD} />
) : (selectedElementIds && selectedElementIds.size >= 2) ? (
<MultiSelectActions
selectedIds={selectedElementIds}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
clearSelection={clearSelection || (() => {})}
/>
) : selectedElementId ? (
<ElementPropertiesPanel
designMD={designMD}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
timeUnit={timeUnit}
activeLayerId={activeLayerId}
outputFormat={outputFormat}
/>
) : activeLayerId && layers.find(l => l.id === activeLayerId)?.type === 'audio' ? (
<AudioLayerPanel
activeLayerId={activeLayerId}
setTimelineElements={setTimelineElements}
timelineElements={timelineElements}
playerRef={playerRef}
endFrameLimit={timelineElements.find(el => layers.find(l => l.id === el.layerId)?.type === 'background')?.endFrame || 150}
/>
) : activeLayerId && layers.find(l => l.id === activeLayerId)?.type === 'visual' ? (
<GraphicLayerPanel />
) : (
<GlobalSettingsPanel
textOverlay={textOverlay}
setTextOverlay={setTextOverlay}
onExportClick={onExportClick}
onShowRenderHistory={onShowRenderHistory}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
layers={layers}
showGrid={showGrid}
setShowGrid={setShowGrid}
showSafeZone={showSafeZone}
setShowSafeZone={setShowSafeZone}
/>
)}
</div>
{/* Layers panel — image mode only (replaces the hidden timeline) */}
{isImageMode && (
<div className="flex-1 min-h-0 border-t border-neutral-800">
<ImageLayersPanel
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
layers={layers}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
/>
</div>
)}
</aside>
);
};
+730
View File
@@ -0,0 +1,730 @@
import React, { useState, useEffect, useRef, RefObject } from 'react';
import { Layers, GripVertical } from 'lucide-react';
import { TimelineElement, TimelineLayer, DesignMD } from '../types';
import { PlayerRef } from '@remotion/player';
import { DragState, getTrackBgClass } from './timeline/timelineUtils';
import { TimelineControls } from './timeline/TimelineControls';
import { TimelineRuler } from './timeline/TimelineRuler';
import { TimelinePlayhead } from './timeline/TimelinePlayhead';
import { TimelineTrackElement } from './timeline/TimelineTrackElement';
import { TimelineLayerLabels } from './timeline/TimelineLayerLabels';
import { LayerContextMenu } from './timeline/LayerContextMenu';
import { ElementContextMenu } from './timeline/ElementContextMenu';
import { insertSceneTemplate, SceneTemplate } from '../config/sceneTemplates';
import { getAudioDuration, durationToFrames } from '../utils/audioMetadata';
interface StudioTimelineProps {
timelineZoom: number;
setTimelineZoom: (zoom: number) => void;
timeUnit: 'frames' | 'seconds';
setTimeUnit: (unit: 'frames' | 'seconds') => void;
durationInFrames: number;
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
layers: TimelineLayer[];
setLayers: React.Dispatch<React.SetStateAction<TimelineLayer[]>>;
activeLayerId: string;
setActiveLayerId: (id: string) => void;
selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void;
playerRef: RefObject<PlayerRef | null>;
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
outputFormat?: 'video' | 'image';
designMD?: DesignMD;
selectedElementIds?: Set<string>;
toggleElementSelection?: (id: string, multi?: boolean) => void;
}
export const StudioTimeline: React.FC<StudioTimelineProps> = ({
timelineZoom,
setTimelineZoom,
timeUnit,
setTimeUnit,
durationInFrames,
timelineElements,
setTimelineElements,
layers,
setLayers,
activeLayerId,
setActiveLayerId,
selectedElementId,
setSelectedElementId,
playerRef,
activeTool,
outputFormat,
designMD,
selectedElementIds,
toggleElementSelection,
}) => {
const timelineRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);
const [expandedLayers, setExpandedLayers] = useState<Record<string, boolean>>({});
const [draggedLayerId, setDraggedLayerId] = useState<string | null>(null);
const [dragMousePos, setDragMousePos] = useState<{ x: number, y: number } | null>(null);
const [layerContextMenu, setLayerContextMenu] = useState<{ layerId: string, x: number, y: number } | null>(null);
const [elementContextMenu, setElementContextMenu] = useState<{ elementId: string, x: number, y: number } | null>(null);
const [snapGuideFrame, setSnapGuideFrame] = useState<number | null>(null);
const [markers, setMarkers] = useState<number[]>([]);
const transparentImg = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const img = new Image();
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
transparentImg.current = img;
const handleClickOutside = () => setLayerContextMenu(null);
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
// ═══ Playhead Auto-scroll ═══
useEffect(() => {
const player = playerRef.current;
if (!player) return;
let isPlaying = false;
let rafId: number | null = null;
const handlePlay = () => { isPlaying = true; };
const handlePause = () => { isPlaying = false; };
const checkScroll = () => {
if (!isPlaying || !scrollContainerRef.current || !timelineRef.current) {
rafId = requestAnimationFrame(checkScroll);
return;
}
const currentFrame = player.getCurrentFrame();
const scrollEl = scrollContainerRef.current;
const trackWidth = timelineRef.current.offsetWidth;
const playheadX = (currentFrame / durationInFrames) * trackWidth;
const viewportLeft = scrollEl.scrollLeft;
const viewportRight = viewportLeft + scrollEl.clientWidth;
const margin = scrollEl.clientWidth * 0.15; // 15% lookahead margin
if (playheadX > viewportRight - margin || playheadX < viewportLeft + margin * 0.3) {
scrollEl.scrollTo({
left: playheadX - scrollEl.clientWidth * 0.3,
behavior: 'smooth',
});
}
rafId = requestAnimationFrame(checkScroll);
};
player.addEventListener('play', handlePlay);
player.addEventListener('pause', handlePause);
player.addEventListener('ended', handlePause);
rafId = requestAnimationFrame(checkScroll);
return () => {
player.removeEventListener('play', handlePlay);
player.removeEventListener('pause', handlePause);
player.removeEventListener('ended', handlePause);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [playerRef, durationInFrames]);
const toggleLayer = (layerId: string) => {
setExpandedLayers(prev => ({ ...prev, [layerId]: !prev[layerId] }));
};
// --- Layer Drag & Drop ---
const handleDragLayerStart = (e: React.DragEvent, id: string) => {
e.dataTransfer.setData('text/plain', id);
if (transparentImg.current) {
e.dataTransfer.setDragImage(transparentImg.current, 0, 0);
}
setTimeout(() => setDraggedLayerId(id), 0);
};
const handleDropLayer = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
if (!draggedLayerId || draggedLayerId === targetId) {
setDraggedLayerId(null);
return;
}
const oldIndex = layers.findIndex(l => l.id === draggedLayerId);
const newIndex = layers.findIndex(l => l.id === targetId);
if (oldIndex === -1 || newIndex === -1) {
setDraggedLayerId(null);
return;
}
const newLayers = [...layers];
const [removed] = newLayers.splice(oldIndex, 1);
newLayers.splice(newIndex, 0, removed);
setLayers(newLayers);
setDraggedLayerId(null);
};
// --- Layer Actions ---
const handleToggleLayerLock = (layerId: string) => {
setLayers(layers.map(l => l.id === layerId ? { ...l, isLocked: !l.isLocked } : l));
setLayerContextMenu(null);
};
const handleDuplicateLayer = (layerId: string) => {
const layerToDup = layers.find(l => l.id === layerId);
if (!layerToDup) return;
const newLayerId = 'layer-' + Date.now();
const newLayer = { ...layerToDup, id: newLayerId, name: `${layerToDup.name} (Copia)` };
const layerElements = timelineElements.filter(el => el.layerId === layerId);
const newElements = layerElements.map(el => ({ ...el, id: el.id + '-copy-' + Date.now(), layerId: newLayerId }));
const layerIndex = layers.findIndex(l => l.id === layerId);
const newLayers = [...layers];
newLayers.splice(layerIndex + 1, 0, newLayer);
setLayers(newLayers);
setTimelineElements([...timelineElements, ...newElements]);
setLayerContextMenu(null);
};
const handleDeleteLayer = (layerId: string) => {
setLayers(layers.filter(l => l.id !== layerId));
setTimelineElements(timelineElements.filter(el => el.layerId !== layerId));
if (activeLayerId === layerId) {
const remaining = layers.filter(l => l.id !== layerId);
const fallback = remaining.find(l => l.type !== 'brand') || remaining[0];
if (fallback) setActiveLayerId(fallback.id);
}
setLayerContextMenu(null);
};
// --- Playhead Scrubbing ---
const handleRulerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!timelineRef.current || !playerRef.current) return;
setIsDraggingPlayhead(true);
e.currentTarget.setPointerCapture(e.pointerId);
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const frame = Math.round(percentage * durationInFrames);
playerRef.current.seekTo(frame);
};
const handleRulerPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDraggingPlayhead || !timelineRef.current || !playerRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const frame = Math.round(percentage * durationInFrames);
playerRef.current.seekTo(frame);
};
const handleRulerPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
setIsDraggingPlayhead(false);
e.currentTarget.releasePointerCapture(e.pointerId);
};
// Stable refs for drag handler to prevent effect re-runs mid-drag
const durationRef = useRef(durationInFrames);
durationRef.current = durationInFrames;
const setElementsRef = useRef(setTimelineElements);
setElementsRef.current = setTimelineElements;
// --- Element Drag (move/resize) ---
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragState || !timelineRef.current) return;
const containerWidth = timelineRef.current.clientWidth;
const dur = durationRef.current;
const pxPerFrame = containerWidth / dur;
const deltaPx = e.clientX - dragState.startX;
const deltaFrames = Math.round(deltaPx / pxPerFrame);
setElementsRef.current(prev => {
const draggingId = dragState.id;
const element = prev.find(el => el.id === draggingId);
if (!element) return prev;
let newStart = element.startFrame;
let newEnd = element.endFrame;
if (dragState.type === 'move') {
const duration = dragState.initialEndFrame - dragState.initialStartFrame;
newStart = dragState.initialStartFrame + deltaFrames;
newEnd = newStart + duration;
} else if (dragState.type === 'resize-start') {
newStart = dragState.initialStartFrame + deltaFrames;
} else if (dragState.type === 'resize-end') {
newEnd = dragState.initialEndFrame + deltaFrames;
}
// Snapping
const SNAP_THRESHOLD_PX = 10;
const dynamicSnapThreshold = Math.max(2, Math.round(SNAP_THRESHOLD_PX / pxPerFrame));
const snapPoints: number[] = [0, dur];
if (playerRef.current) {
const playhead = playerRef.current.getCurrentFrame();
if (playhead !== null) snapPoints.push(playhead);
}
prev.forEach(el => {
if (el.id !== draggingId && el.layerId === element.layerId) {
snapPoints.push(el.startFrame, el.endFrame);
}
});
let closestStartSnap = Infinity;
let closestEndSnap = Infinity;
for (const sp of snapPoints) {
if (Math.abs(sp - newStart) < Math.abs(closestStartSnap)) closestStartSnap = sp - newStart;
if (Math.abs(sp - newEnd) < Math.abs(closestEndSnap)) closestEndSnap = sp - newEnd;
}
let activeSnapFrame: number | null = null;
if (dragState.type === 'move') {
if (Math.abs(closestStartSnap) <= dynamicSnapThreshold && Math.abs(closestStartSnap) <= Math.abs(closestEndSnap)) {
newStart += closestStartSnap;
newEnd = newStart + (dragState.initialEndFrame - dragState.initialStartFrame);
activeSnapFrame = newStart;
} else if (Math.abs(closestEndSnap) <= dynamicSnapThreshold) {
newEnd += closestEndSnap;
newStart = newEnd - (dragState.initialEndFrame - dragState.initialStartFrame);
activeSnapFrame = newEnd;
}
} else if (dragState.type === 'resize-start' && Math.abs(closestStartSnap) <= dynamicSnapThreshold) {
newStart += closestStartSnap;
if (newStart >= newEnd) newStart = newEnd - 1;
activeSnapFrame = newStart;
} else if (dragState.type === 'resize-end' && Math.abs(closestEndSnap) <= dynamicSnapThreshold) {
newEnd += closestEndSnap;
if (newEnd <= newStart) newEnd = newStart + 1;
activeSnapFrame = newEnd;
}
setTimeout(() => setSnapGuideFrame(activeSnapFrame), 0);
if (dragState.type === 'move') {
const duration = dragState.initialEndFrame - dragState.initialStartFrame;
newStart = Math.max(0, Math.min(dur - duration, newStart));
newEnd = Math.max(duration, Math.min(dur, newEnd));
} else if (dragState.type === 'resize-start') {
newStart = Math.max(0, Math.min(dragState.initialEndFrame - 1, newStart));
} else if (dragState.type === 'resize-end') {
newEnd = Math.max(newStart + 1, Math.min(dur, newEnd));
}
// Cross-layer dragging: detect vertical movement
let newLayerId = element.layerId;
if (dragState.type === 'move' && dragState.startY) {
const deltaY = e.clientY - dragState.startY;
const LAYER_HEIGHT = 40; // approximate height of each track row
if (Math.abs(deltaY) > LAYER_HEIGHT * 0.5) {
const layerSteps = Math.round(deltaY / LAYER_HEIGHT);
const initialLayerIdx = sortedTrackLayers.findIndex(l => l.id === (dragState.initialLayerId || element.layerId));
const targetIdx = Math.max(0, Math.min(sortedTrackLayers.length - 1, initialLayerIdx + layerSteps));
const targetLayer = sortedTrackLayers[targetIdx];
if (targetLayer && targetLayer.id !== element.layerId && targetLayer.type !== 'brand') {
newLayerId = targetLayer.id;
}
}
}
if (newStart === element.startFrame && newEnd === element.endFrame && newLayerId === element.layerId) return prev;
return prev.map(el => el.id === draggingId ? { ...el, startFrame: newStart, endFrame: newEnd, layerId: newLayerId } : el);
});
};
const handleMouseUp = () => {
if (dragState) {
setDragState(null);
setSnapGuideFrame(null);
}
};
if (dragState) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragState]);
// --- Split Element ---
const handleSplitElement = () => {
if (!selectedElementId || !playerRef.current) return;
const currentFrame = playerRef.current.getCurrentFrame() || 0;
const element = timelineElements.find(el => el.id === selectedElementId);
if (element && currentFrame > element.startFrame && currentFrame < element.endFrame) {
const el1 = { ...element, endFrame: currentFrame };
const el2 = { ...element, id: Date.now().toString(), startFrame: currentFrame };
setTimelineElements(prev => prev.map(el => el.id === selectedElementId ? el1 : el).concat(el2));
setSelectedElementId(el2.id);
}
};
// Split & Marker keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return;
if (e.key.toLowerCase() === 's' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleSplitElement();
} else if (e.key.toLowerCase() === 'm' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
const frame = playerRef.current?.getCurrentFrame() ?? 0;
setMarkers(prev => {
const existing = prev.findIndex(m => Math.abs(m - frame) < 3);
if (existing >= 0) {
// Remove marker
return prev.filter((_, i) => i !== existing);
}
// Add marker
return [...prev, frame].sort((a, b) => a - b);
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedElementId, timelineElements, playerRef]);
// --- Sorted layers for track display ---
const sortedTrackLayers = [
...layers.filter(l => l.type === 'brand'),
...layers.filter(l => l.type === 'background'),
...layers.filter(l => outputFormat !== 'image' && l.type === 'video'),
...layers.filter(l => outputFormat !== 'image' && l.type === 'audio'),
...layers.filter(l => l.type === 'visual' || l.type == null)
];
return (
<div className="StudioTimeline h-64 bg-neutral-900 border-t border-neutral-800 flex flex-col shrink-0 overflow-hidden shadow-[0_-10px_20px_rgba(0,0,0,0.3)]">
<TimelineControls
timelineZoom={timelineZoom}
setTimelineZoom={setTimelineZoom}
timeUnit={timeUnit}
setTimeUnit={setTimeUnit}
durationInFrames={durationInFrames}
selectedElementId={selectedElementId}
onSplit={handleSplitElement}
outputFormat={outputFormat}
onInsertTemplate={(template: SceneTemplate) => {
const frame = playerRef.current?.getCurrentFrame() ?? 0;
const newElements = insertSceneTemplate(template, activeLayerId, frame);
setTimelineElements(prev => [...prev, ...newElements]);
if (newElements.length > 0) setSelectedElementId(newElements[0].id);
}}
/>
{/* Tracks Header & Timeline Ruler Container */}
<div className="flex-1 overflow-hidden flex relative">
{/* Track Labels (Left Side) */}
<TimelineLayerLabels
layers={layers}
setLayers={setLayers}
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
activeLayerId={activeLayerId}
setActiveLayerId={setActiveLayerId}
selectedElementId={selectedElementId}
setSelectedElementId={setSelectedElementId}
expandedLayers={expandedLayers}
toggleLayer={toggleLayer}
draggedLayerId={draggedLayerId}
onDragLayerStart={handleDragLayerStart}
onDropLayer={handleDropLayer}
setDraggedLayerId={setDraggedLayerId}
setDragMousePos={setDragMousePos}
setLayerContextMenu={setLayerContextMenu}
playerRef={playerRef}
outputFormat={outputFormat}
durationInFrames={durationInFrames}
designMD={designMD}
/>
{/* Tracks Content (Scrollable) */}
{outputFormat !== 'image' && (
<div ref={scrollContainerRef} className="flex-1 overflow-x-auto overflow-y-auto custom-scrollbar relative bg-[url('https://www.transparenttextures.com/patterns/black-linen.png')] bg-neutral-950/40">
<div
ref={timelineRef}
className="relative min-h-full pb-8 select-none cursor-text"
style={{ width: `${Math.max(100, timelineZoom * 100)}%`, minWidth: '100%' }}
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
handleRulerPointerDown(e);
}
}}
onPointerMove={isDraggingPlayhead ? handleRulerPointerMove : undefined}
onPointerUp={isDraggingPlayhead ? handleRulerPointerUp : undefined}
>
{/* Ruler */}
<TimelineRuler
timeUnit={timeUnit}
durationInFrames={durationInFrames}
onPointerDown={handleRulerPointerDown}
onPointerMove={handleRulerPointerMove}
onPointerUp={handleRulerPointerUp}
/>
{/* Playhead */}
<TimelinePlayhead
playerRef={playerRef}
durationInFrames={durationInFrames}
onPointerDown={handleRulerPointerDown}
onPointerMove={handleRulerPointerMove}
onPointerUp={handleRulerPointerUp}
isDraggingPlayhead={isDraggingPlayhead}
/>
{/* Snap Guide */}
{snapGuideFrame !== null && (
<div
className="absolute top-0 bottom-0 w-px bg-yellow-400 z-20 shadow-[0_0_8px_rgba(250,204,21,0.8)] pointer-events-none"
style={{ left: `${(snapGuideFrame / durationInFrames) * 100}%` }}
/>
)}
{/* Markers */}
{markers.map((frame, idx) => (
<div
key={`marker-${idx}`}
className="absolute top-0 bottom-0 z-15 pointer-events-none"
style={{ left: `${(frame / durationInFrames) * 100}%` }}
>
{/* Marker head (triangle) */}
<div className="absolute top-0 -translate-x-1/2" style={{ width: 0, height: 0, borderLeft: '4px solid transparent', borderRight: '4px solid transparent', borderTop: '6px solid #34d399' }} />
{/* Marker line */}
<div className="absolute top-1.5 bottom-0 w-px bg-emerald-400/30" style={{ left: 0 }} />
</div>
))}
{/* Tracks */}
<div className="py-2 space-y-2 w-full">
{sortedTrackLayers.map(layer => {
const isExpanded = expandedLayers[layer.id];
const layerElements = timelineElements.filter(el => el.layerId === layer.id);
return (
<div key={`layer-track-group-${layer.id}`} className={`flex flex-col gap-2 rounded-l -ml-1 pl-1 ${getTrackBgClass(layer.colorLabel)}`}>
<div
className="relative h-8 w-full group"
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}}
onDrop={(e) => {
e.preventDefault();
const data = e.dataTransfer.getData('application/json');
if (data) {
try {
const parsed = JSON.parse(data);
if (parsed.type && parsed.src) {
const elementType = parsed.type === 'sticker' ? 'image' : (parsed.type === 'images' || parsed.type === 'image') ? 'image' : parsed.type === 'video' ? 'video' : parsed.type === 'audio' ? 'audio' : 'image';
// Validate layer compatibility
if (layer.type === 'brand') return; // Brand never accepts drops
if (layer.type === 'video' && elementType !== 'video') return;
if (layer.type === 'audio' && elementType !== 'audio') return;
if ((layer.type === 'visual' || layer.type == null) && (elementType === 'video' || elementType === 'audio')) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, x / rect.width));
const frame = Math.round(percentage * durationInFrames);
setTimelineElements(prev => [...prev, {
id: 'el-' + Date.now(),
layerId: layer.id,
type: elementType,
content: parsed.src,
startFrame: frame,
endFrame: Math.min(durationInFrames, frame + 60),
x: 0,
y: 0,
scale: 1,
originalFileName: parsed.fileName,
}]);
// Auto-detect audio duration and update endFrame
if (elementType === 'audio') {
const elId = 'el-' + Date.now();
getAudioDuration(parsed.src).then(dur => {
const realEnd = frame + durationToFrames(dur);
setTimelineElements(p => p.map(el =>
el.content === parsed.src && el.startFrame === frame && el.type === 'audio'
? { ...el, endFrame: realEnd }
: el
));
}).catch(() => {});
}
}
} catch (err) {}
}
}}
>
{/* Track Background Line */}
<div className="absolute top-1/2 w-full h-px bg-neutral-800/30 transform -translate-y-1/2"></div>
{!isExpanded && layerElements.map(el => (
<TimelineTrackElement
key={`track-${el.id}`}
element={el}
layer={layer}
layerElements={layerElements}
durationInFrames={durationInFrames}
selectedElementId={selectedElementId}
dragState={dragState}
activeTool={activeTool}
setSelectedElementId={setSelectedElementId}
setActiveLayerId={setActiveLayerId}
setDragState={setDragState}
setTimelineElements={setTimelineElements}
setElementContextMenu={setElementContextMenu}
playerRef={playerRef}
timelineElements={timelineElements}
selectedElementIds={selectedElementIds}
toggleElementSelection={toggleElementSelection}
/>
))}
</div>
{isExpanded && (
<div className="flex flex-col gap-2">
{layerElements.map((el, elIdx) => (
<div key={`track-el-${el.id}`} className="relative h-8 w-full group flex items-center">
<div className="absolute top-1/2 w-full h-px bg-neutral-800/20 transform -translate-y-1/2"></div>
<div className="sticky left-4 z-[5] w-fit flex items-center gap-1.5 opacity-40 group-hover:opacity-100 transition-opacity pointer-events-none">
<span className="text-[9px] text-neutral-400 font-mono font-medium bg-neutral-900/90 w-4 h-4 flex items-center justify-center rounded border border-neutral-800 shadow-sm backdrop-blur-md">
{elIdx + 1}
</span>
<span className="text-[9px] text-neutral-400 font-medium bg-neutral-900/90 px-1.5 py-0.5 rounded border border-neutral-800 shadow-sm backdrop-blur-md">
{layer.name}
</span>
<span className="text-[9px] text-neutral-600 font-medium truncate max-w-[150px]">
{el.type === 'text' ? el.content : el.type === 'audio' ? 'Audio Track' : el.type === 'image' ? 'Imagen' : el.type === 'video' ? 'Video' : 'Sticker'}
</span>
</div>
<TimelineTrackElement
element={el}
layer={layer}
layerElements={layerElements}
durationInFrames={durationInFrames}
selectedElementId={selectedElementId}
dragState={dragState}
activeTool={activeTool}
setSelectedElementId={setSelectedElementId}
setActiveLayerId={setActiveLayerId}
setDragState={setDragState}
setTimelineElements={setTimelineElements}
setElementContextMenu={setElementContextMenu}
playerRef={playerRef}
timelineElements={timelineElements}
selectedElementIds={selectedElementIds}
toggleElementSelection={toggleElementSelection}
/>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
{/* Context Menu layer */}
{layerContextMenu && (
<LayerContextMenu
layerContextMenu={layerContextMenu}
layers={layers}
setLayers={setLayers}
onToggleLock={handleToggleLayerLock}
onDuplicate={handleDuplicateLayer}
onDelete={handleDeleteLayer}
onClose={() => setLayerContextMenu(null)}
/>
)}
{/* Context Menu element */}
{elementContextMenu && (() => {
const ctxEl = timelineElements.find(e => e.id === elementContextMenu.elementId);
if (!ctxEl) return null;
return (
<ElementContextMenu
elementId={elementContextMenu.elementId}
x={elementContextMenu.x}
y={elementContextMenu.y}
element={ctxEl}
onClose={() => setElementContextMenu(null)}
onDuplicate={(id) => {
const src = timelineElements.find(e => e.id === id);
if (!src || src.isBrandElement) return;
const copy = { ...src, id: 'el-' + Date.now(), isBrandElement: false, isLocked: false };
setTimelineElements(prev => [...prev, copy]);
setSelectedElementId(copy.id);
}}
onDelete={(id) => {
setTimelineElements(prev => prev.filter(e => e.id !== id));
setSelectedElementId(null);
}}
onToggleLock={(id) => {
setTimelineElements(prev => prev.map(e =>
e.id === id ? { ...e, isLocked: !e.isLocked } : e
));
}}
onSplit={(id) => {
const splitEl = timelineElements.find(e => e.id === id);
const frame = playerRef.current?.getCurrentFrame() ?? 0;
if (splitEl && frame > splitEl.startFrame + 2 && frame < splitEl.endFrame - 2) {
const second = { ...splitEl, id: 'el-' + Date.now(), startFrame: frame, isBrandElement: false };
setTimelineElements(prev => {
const idx = prev.findIndex(e => e.id === id);
const arr = [...prev];
arr[idx] = { ...splitEl, endFrame: frame };
arr.splice(idx + 1, 0, second);
return arr;
});
}
}}
onBringForward={(id) => {
setTimelineElements(prev => {
const idx = prev.findIndex(e => e.id === id);
if (idx < 0 || idx === prev.length - 1) return prev;
const arr = [...prev];
[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]];
return arr;
});
}}
onSendBackward={(id) => {
setTimelineElements(prev => {
const idx = prev.findIndex(e => e.id === id);
if (idx <= 0) return prev;
const arr = [...prev];
[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
return arr;
});
}}
/>
);
})()}
{/* Drag Ghost Indicator */}
{draggedLayerId && dragMousePos && (
<div
className="fixed pointer-events-none z-[100] w-64 bg-neutral-800/90 backdrop-blur-sm border border-neutral-600 rounded shadow-2xl flex items-center px-2 h-8 gap-1.5"
style={{ left: dragMousePos.x + 15, top: dragMousePos.y + 15 }}
>
<GripVertical size={12} className="text-neutral-400" />
<Layers size={12} className="text-neutral-300 shrink-0" />
<span className="text-[10px] font-medium text-white truncate px-1">
{layers.find(l => l.id === draggedLayerId)?.name || 'Capa'}
</span>
</div>
)}
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import { FolderOpen, Type, Stamp, Music, Settings2, Hexagon, HelpCircle, Disc3 } from 'lucide-react';
export type PanelType = 'media' | 'text' | 'stickers' | 'shapes' | 'audio' | 'sfx' | null;
interface StudioToolbarProps {
activePanel: PanelType;
setActivePanel: (panel: PanelType) => void;
onShowShortcuts?: () => void;
outputFormat?: 'video' | 'image';
}
/**
* CapCut-style sidebar toolbar (56px wide).
* Each button toggles a sliding panel on the right side.
* Always visible — no buttons change or disappear based on layer type.
*/
export const StudioToolbar: React.FC<StudioToolbarProps> = ({
activePanel,
setActivePanel,
onShowShortcuts,
outputFormat,
}) => {
const ToolButton = ({ panel, icon, label }: { panel: PanelType; icon: React.ReactNode; label: string }) => {
const isActive = activePanel === panel;
return (
<button
onClick={() => setActivePanel(isActive ? null : panel)}
title={label}
className={`relative w-full flex flex-col items-center justify-center gap-0.5 py-2.5 transition-all ${
isActive
? 'text-white bg-neutral-800/70'
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/30'
}`}
>
{/* Active accent line */}
{isActive && (
<div className="absolute left-0 top-1.5 bottom-1.5 w-[2px] bg-violet-500 rounded-r" />
)}
{icon}
<span className="text-[8px] font-medium leading-none mt-0.5">{label}</span>
</button>
);
};
return (
<div className="w-14 bg-neutral-900 border-r border-neutral-800/60 flex flex-col items-center z-20 shrink-0">
<ToolButton
panel="media"
icon={<FolderOpen size={18} />}
label="Media"
/>
<ToolButton
panel="text"
icon={<Type size={18} />}
label="Texto"
/>
<ToolButton
panel="stickers"
icon={<Stamp size={18} />}
label="Marca"
/>
<ToolButton
panel="shapes"
icon={<Hexagon size={18} />}
label="Formas"
/>
{outputFormat !== 'image' && (
<ToolButton
panel="audio"
icon={<Music size={18} />}
label="Audio"
/>
)}
{outputFormat !== 'image' && (
<ToolButton
panel="sfx"
icon={<Disc3 size={18} />}
label="SFX"
/>
)}
<div className="flex-1" />
{/* Help */}
<button
onClick={onShowShortcuts}
title="Atajos de Teclado (?)"
className="relative w-full flex flex-col items-center justify-center gap-0.5 py-2.5 transition-all text-neutral-600 hover:text-neutral-300 hover:bg-neutral-800/30"
>
<HelpCircle size={18} />
<span className="text-[8px] font-medium leading-none mt-0.5">Ayuda</span>
</button>
{/* Settings */}
<button
onClick={() => setActivePanel(null)}
title="Cerrar Paneles"
className={`relative w-full flex flex-col items-center justify-center gap-0.5 py-2.5 mb-1 transition-all ${
activePanel === null
? 'text-white bg-neutral-800/60'
: 'text-neutral-600 hover:text-neutral-300 hover:bg-neutral-800/30'
}`}
>
<Settings2 size={18} />
<span className="text-[8px] font-medium leading-none mt-0.5">Ajustes</span>
</button>
</div>
);
};
+626
View File
@@ -0,0 +1,626 @@
import React, { RefObject, useState, useCallback, useEffect } from 'react';
import { Player, PlayerRef } from '@remotion/player';
import { BrandComposition } from './BrandComposition';
import { RenderProps, TimelineElement } from '../types';
import { PlaySquare } from 'lucide-react';
import { CanvasWorkspace } from './ui/CanvasWorkspace';
import { useEditor } from '../context/EditorContext';
import { ElementActionToolbar } from './composition/ElementActionToolbar';
import { SAFE_AREAS } from '../config/constants';
import { getAudioDuration, durationToFrames } from '../utils/audioMetadata';
interface StudioWorkspaceProps {
activeTool: 'select' | 'text' | 'sticker' | 'media' | 'transitions';
setSelectedElementId: (id: string | null) => void;
selectedElementId?: string | null;
playerRef: RefObject<PlayerRef | null>;
compositionProps: RenderProps;
durationInFrames: number;
timelineElements?: TimelineElement[];
setTimelineElements?: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
aspectRatio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3';
setAspectRatio: (ratio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3') => void;
outputFormat?: 'video' | 'image';
activeLayerId?: string;
/** Lifted zoom state for TopHeader integration */
zoom: number;
setZoom: React.Dispatch<React.SetStateAction<number>>;
}
export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
activeTool,
setSelectedElementId,
selectedElementId,
playerRef,
compositionProps,
durationInFrames,
timelineElements,
setTimelineElements,
aspectRatio,
setAspectRatio,
outputFormat,
activeLayerId,
zoom,
setZoom,
}) => {
const [showControls, setShowControls] = useState(false);
const [showContextMenu, setShowContextMenu] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const lastPanPos = React.useRef({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [safeAreaPlatform, setSafeAreaPlatform] = useState<keyof typeof SAFE_AREAS | null>(null);
const { activeAction, setActiveAction } = useEditor();
// Keyboard shortcuts for action modes (M/S/R) and element actions (D/Delete)
useEffect(() => {
if (!selectedElementId) return;
const handleKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const selectedEl = timelineElements?.find(el => el.id === selectedElementId);
if (!selectedEl) return;
switch (e.key.toLowerCase()) {
case 'm': setActiveAction('move'); break;
case 's': setActiveAction('scale'); break;
case 'r': setActiveAction('rotate'); break;
case 'd':
e.preventDefault();
compositionProps.onElementDuplicate?.(selectedEl.id);
break;
case 'backspace':
case 'delete':
if (!selectedEl.isBrandElement) {
e.preventDefault();
compositionProps.onElementDelete?.(selectedEl.id);
}
break;
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [selectedElementId, timelineElements, setActiveAction, compositionProps]);
// Reset to move when element is deselected
useEffect(() => {
if (!selectedElementId) setActiveAction('move');
}, [selectedElementId, setActiveAction]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' && (e.target as HTMLElement).tagName !== 'INPUT' && (e.target as HTMLElement).tagName !== 'TEXTAREA') {
setIsSpacePressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
setIsSpacePressed(false);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
}
}, []);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button === 1 || isSpacePressed) {
e.preventDefault();
setIsPanning(true);
lastPanPos.current = { x: e.clientX, y: e.clientY };
} else {
setSelectedElementId(null);
}
};
const handlePointerMove = (e: React.PointerEvent) => {
if (isPanning) {
const dx = e.clientX - lastPanPos.current.x;
const dy = e.clientY - lastPanPos.current.y;
setPan(prev => ({ x: prev.x + dx, y: prev.y + dy }));
lastPanPos.current = { x: e.clientX, y: e.clientY };
}
};
const handlePointerUp = () => {
setIsPanning(false);
};
// Ref for native wheel handler (React onWheel is passive, can't preventDefault)
const canvasRef = React.useRef<HTMLElement>(null);
React.useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
const zoomDelta = -e.deltaY * 0.008;
setZoom(prev => {
const next = Math.min(5, Math.max(0.1, prev + zoomDelta * prev));
return Math.round(next * 100) / 100;
});
} else {
setPan(prev => ({ x: prev.x - e.deltaX, y: prev.y - e.deltaY }));
}
};
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [setZoom]);
const selectedElement = timelineElements?.find(el => el.id === selectedElementId);
const handleUpdateSelected = (updates: Partial<TimelineElement>) => {
if (setTimelineElements && selectedElementId) {
setTimelineElements(prev => prev.map(el => el.id === selectedElementId ? { ...el, ...updates } : el));
}
};
const handleTextEditComplete = () => {
setEditingTextId(null);
};
const { layers, setLayers, setActiveLayerId } = useEditor();
// ═══ Drop handler for canvas ═══
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const data = e.dataTransfer.getData('application/json');
if (!data || !setTimelineElements) return;
try {
const parsed = JSON.parse(data);
if (!parsed.type || !parsed.src) return;
// Calculate drop position relative to canvas
const canvasRect = e.currentTarget.getBoundingClientRect();
const x = Math.round(((e.clientX - canvasRect.left) / canvasRect.width) * 100);
const y = Math.round(((e.clientY - canvasRect.top) / canvasRect.height) * 100);
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const elementType = parsed.type === 'sticker' ? 'image'
: (parsed.type === 'images' || parsed.type === 'image') ? 'image'
: parsed.type === 'video' ? 'video'
: parsed.type === 'audio' ? 'audio'
: 'image';
// Determine target layer based on element type
let targetLayerId = activeLayerId || 'layer-1';
const activeLayer = layers.find(l => l.id === activeLayerId);
// Brand layer never accepts new elements — route to correct layer
const isIncompatibleLayer = activeLayer?.type === 'brand'
|| (elementType === 'video' && activeLayer?.type !== 'video')
|| (elementType === 'audio' && activeLayer?.type !== 'audio')
|| (elementType !== 'video' && elementType !== 'audio' && (activeLayer?.type === 'video' || activeLayer?.type === 'audio'));
if (elementType === 'video' && (isIncompatibleLayer || activeLayer?.type !== 'video')) {
let videoLayer = layers.find(l => l.type === 'video');
if (!videoLayer) {
const count = layers.filter(l => l.type === 'video').length + 1;
videoLayer = { id: 'layer-' + Date.now(), name: `Capa de Video ${count}`, type: 'video' };
setLayers(prev => [...prev, videoLayer!]);
}
targetLayerId = videoLayer.id;
setActiveLayerId(targetLayerId);
} else if (elementType === 'audio' && (isIncompatibleLayer || activeLayer?.type !== 'audio')) {
let audioLayer = layers.find(l => l.type === 'audio');
if (!audioLayer) {
const count = layers.filter(l => l.type === 'audio').length + 1;
audioLayer = { id: 'layer-' + Date.now(), name: `Capa de Audio ${count}`, type: 'audio', volume: 100 };
setLayers(prev => [...prev, audioLayer!]);
}
targetLayerId = audioLayer.id;
setActiveLayerId(targetLayerId);
} else if (isIncompatibleLayer) {
// Image/sticker → visual layer
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (visualLayer) {
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
}
setTimelineElements(prev => [...prev, {
id: 'el-' + Date.now(),
layerId: targetLayerId,
type: elementType,
content: parsed.src,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: Math.max(5, Math.min(95, x)),
y: Math.max(5, Math.min(95, y)),
scale: 1,
originalFileName: parsed.fileName,
}]);
// Auto-detect audio duration and update endFrame
if (elementType === 'audio') {
getAudioDuration(parsed.src).then(dur => {
const realEnd = currentFrame + durationToFrames(dur);
setTimelineElements(p => p.map(el =>
el.content === parsed.src && el.startFrame === currentFrame && el.type === 'audio'
? { ...el, endFrame: realEnd }
: el
));
}).catch(() => {});
}
} catch {}
}, [setTimelineElements, activeLayerId, durationInFrames, playerRef, layers, setLayers, setActiveLayerId]);
const getDimensions = () => {
if (aspectRatio === '16:9') return { width: 1920, height: 1080 };
if (aspectRatio === '1:1') return { width: 1080, height: 1080 };
if (aspectRatio === '4:5') return { width: 1080, height: 1350 };
if (aspectRatio === '4:3') return { width: 1440, height: 1080 };
return { width: 1080, height: 1920 }; // 9:16
};
const dimensions = getDimensions();
return (
<main
ref={canvasRef}
className={`flex-1 relative flex flex-col justify-center items-center p-4 overflow-hidden checkerboard-bg ${isSpacePressed ? 'cursor-grab' : ''} ${isPanning ? 'cursor-grabbing' : ''} ${isDragOver ? 'drop-zone-active' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
{/* Player controls toggle — video only, top-right small */}
{outputFormat !== 'image' && (
<div className="absolute top-2 right-2 z-10 flex gap-1">
{/* Safe Area toggle */}
{aspectRatio === '9:16' && (
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setSafeAreaPlatform(prev => prev ? null : 'tiktok'); }}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors ${safeAreaPlatform ? 'bg-amber-600/80 text-white' : 'bg-neutral-800/60 hover:bg-neutral-700/60 text-neutral-400'}`}
title="Safe Area"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2" /><rect x="7" y="7" width="10" height="10" rx="1" strokeDasharray="2 2" /></svg>
</button>
{safeAreaPlatform && (
<div className="absolute right-0 top-full mt-1 bg-neutral-900 border border-neutral-700 rounded-lg p-1 shadow-xl min-w-[120px]">
{Object.entries(SAFE_AREAS).map(([key, sa]) => (
<button
key={key}
onClick={(e) => { e.stopPropagation(); setSafeAreaPlatform(key as keyof typeof SAFE_AREAS); }}
className={`block w-full text-left px-2 py-1 rounded text-[10px] transition-colors ${safeAreaPlatform === key ? 'bg-amber-600/30 text-amber-200' : 'text-neutral-300 hover:bg-neutral-800'}`}
>
{sa.label}
</button>
))}
<button
onClick={(e) => { e.stopPropagation(); setSafeAreaPlatform(null); }}
className="block w-full text-left px-2 py-1 rounded text-[10px] text-rose-400 hover:bg-neutral-800"
>
Desactivar
</button>
</div>
)}
</div>
)}
<button
onClick={(e) => { e.stopPropagation(); setShowControls(!showControls); }}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium transition-colors ${showControls ? 'bg-violet-600/80 text-white' : 'bg-neutral-800/60 hover:bg-neutral-700/60 text-neutral-400'}`}
title={showControls ? "Ocultar Controles" : "Mostrar Controles"}
>
<PlaySquare size={12} />
</button>
</div>
)}
{/* ═══ Fixed Action Toolbar — above canvas ═══ */}
{selectedElementId && (() => {
const selectedEl = timelineElements?.find(el => el.id === selectedElementId);
if (!selectedEl) return null;
const isInteractive = (!!activeLayerId && selectedEl.layerId === activeLayerId) && !selectedEl.isLocked;
if (!isInteractive) return null;
// Keyframe state
const currentFrame = playerRef.current?.getCurrentFrame() ?? 0;
const kfs = selectedEl.keyframes ?? [];
const hasKeyframes = kfs.length > 0;
const kfAtFrame = kfs.find(kf => Math.abs(kf.frame - currentFrame) <= 2);
const hasKeyframeAtCurrentFrame = !!kfAtFrame;
const handleToggleKeyframe = () => {
if (!setTimelineElements) return;
setTimelineElements(prev => prev.map(el => {
if (el.id !== selectedEl.id) return el;
const existing = (el.keyframes ?? []);
const atIdx = existing.findIndex(kf => Math.abs(kf.frame - currentFrame) <= 2);
if (atIdx >= 0) {
// Remove keyframe
const newKfs = existing.filter((_, i) => i !== atIdx);
return { ...el, keyframes: newKfs.length > 0 ? newKfs : undefined };
} else {
// Add keyframe with current element values
const newKf = {
frame: currentFrame,
x: el.x,
y: el.y,
scale: el.scale ?? 1,
opacity: el.opacity ?? 1,
rotation: el.rotation ?? 0,
easing: 'ease-in-out' as const,
};
// If no keyframes yet, also add one at startFrame with current values
if (existing.length === 0) {
const startKf = { ...newKf, frame: el.startFrame, easing: 'linear' as const };
return { ...el, keyframes: [startKf, newKf].sort((a, b) => a.frame - b.frame) };
}
return { ...el, keyframes: [...existing, newKf].sort((a, b) => a.frame - b.frame) };
}
}));
};
const handlePrevKeyframe = () => {
const prev = [...kfs].filter(kf => kf.frame < currentFrame - 2).sort((a, b) => b.frame - a.frame);
if (prev.length > 0) playerRef.current?.seekTo(prev[0].frame);
};
const handleNextKeyframe = () => {
const next = [...kfs].filter(kf => kf.frame > currentFrame + 2).sort((a, b) => a.frame - b.frame);
if (next.length > 0) playerRef.current?.seekTo(next[0].frame);
};
return (
<div
className="absolute top-2 left-1/2 -translate-x-1/2 z-20"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<ElementActionToolbar
activeAction={activeAction}
setActiveAction={setActiveAction}
isLocked={!!selectedEl.isLocked}
isBrandElement={!!selectedEl.isBrandElement}
onDuplicate={() => compositionProps.onElementDuplicate?.(selectedEl.id)}
onDelete={() => compositionProps.onElementDelete?.(selectedEl.id)}
onLock={() => compositionProps.onElementLock?.(selectedEl.id)}
hasKeyframes={hasKeyframes}
hasKeyframeAtCurrentFrame={hasKeyframeAtCurrentFrame}
onToggleKeyframe={handleToggleKeyframe}
onPrevKeyframe={handlePrevKeyframe}
onNextKeyframe={handleNextKeyframe}
/>
</div>
);
})()}
<div
className={`relative ${activeTool === 'select' ? 'cursor-default' : 'cursor-crosshair'} h-full w-full max-h-full flex items-center justify-center`}
onClick={(e) => {
e.stopPropagation();
setSelectedElementId(null);
setShowContextMenu(false);
}}
onContextMenu={(e) => {
e.preventDefault();
}}
>
<div
className={`relative flex items-center justify-center ${isPanning ? '' : 'transition-transform duration-100 ease-out'}`}
style={{
height: '100%',
maxHeight: '100%',
maxWidth: '100%',
aspectRatio: `${dimensions.width} / ${dimensions.height}`,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<CanvasWorkspace
aspectRatio={`${dimensions.width} / ${dimensions.height}`}
isEditing={!!selectedElementId}
className=""
canvasClassName="rounded"
overlay={selectedElementId ? (
<>
{/* Text editor overlay */}
{editingTextId && selectedElement && selectedElement.type === 'text' && (
<div
className="absolute z-30"
style={{
left: `${selectedElement.x}%`,
top: `${selectedElement.y}%`,
transform: `translate(-50%, -50%) scale(${selectedElement.scale ?? 1})`,
}}
onClick={e => e.stopPropagation()}
>
<textarea
autoFocus
value={selectedElement.content}
onChange={(e) => handleUpdateSelected({ content: e.target.value })}
onBlur={handleTextEditComplete}
onKeyDown={(e) => {
if (e.key === 'Escape') {
handleTextEditComplete();
}
e.stopPropagation();
}}
className="bg-transparent border-2 border-violet-500 rounded-lg p-2 outline-none resize-none text-center"
style={{
fontFamily: selectedElement.fontFamily ?? compositionProps.designMD.baseFont,
color: selectedElement.color ?? compositionProps.designMD.textColor,
fontSize: `calc(${(selectedElement.fontSize || 56)}px * (100vh / 1920) * 0.8)`,
textShadow: `${selectedElement.shadowOffset ?? 3}px ${selectedElement.shadowOffset ?? 3}px ${selectedElement.shadowBlur ?? 6}px rgba(0,0,0,0.8)`,
width: '600px',
minHeight: '200px',
lineHeight: '1.2',
pointerEvents: 'auto',
}}
/>
</div>
)}
{/* Context menu overlay */}
{selectedElement && showContextMenu && (
<div
className="absolute z-20 flex items-center gap-4 bg-[#111] border border-neutral-800 shadow-2xl px-5 py-3 rounded-2xl backdrop-blur-xl transition-all duration-75"
style={{
left: `${selectedElement.x}%`,
top: `calc(${selectedElement.y}% + 4rem)`,
transform: 'translate(-50%, -50%)',
pointerEvents: 'auto',
}}
onClick={e => e.stopPropagation()}
onContextMenu={e => e.preventDefault()}
>
{selectedElement.type === 'text' && (
<>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Color</label>
<div className="relative w-8 h-8 rounded-lg overflow-hidden border border-neutral-700/50">
<input
type="color"
value={selectedElement.color || compositionProps.designMD.textColor}
onChange={(e) => handleUpdateSelected({ color: e.target.value })}
className="absolute -top-2 -left-2 w-12 h-12 cursor-pointer bg-transparent border-0"
/>
</div>
</div>
<div className="w-[1px] h-10 bg-neutral-800/80 mx-1"></div>
<div className="flex flex-col gap-1.5 w-24">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Tamaño</label>
<input
type="number"
value={selectedElement.fontSize || 56}
onChange={(e) => handleUpdateSelected({ fontSize: Number(e.target.value) })}
className="w-full bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors"
/>
</div>
<div className="w-[1px] h-10 bg-neutral-800/80 mx-1"></div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Fuente</label>
<select
value={selectedElement.fontFamily || compositionProps.designMD.baseFont}
onChange={(e) => handleUpdateSelected({ fontFamily: e.target.value })}
className="bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors min-w-[140px]"
>
<option value="system-ui, sans-serif">System Default</option>
<option value="Inter, sans-serif">Inter</option>
<option value="'Space Grotesk', sans-serif">Space Grotesk</option>
<option value="'Playfair Display', serif">Playfair Display</option>
<option value="'JetBrains Mono', monospace">JetBrains Mono</option>
</select>
</div>
</>
)}
{selectedElement.type === 'sticker' && (
<>
<div className="flex flex-col gap-1.5 w-24">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Escala</label>
<input
type="number"
step="0.1"
min="0.1"
value={selectedElement.scale ?? 1}
onChange={(e) => handleUpdateSelected({ scale: Number(e.target.value) })}
className="w-full bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors"
/>
</div>
<div className="w-[1px] h-10 bg-neutral-800/80 mx-1"></div>
<div className="flex flex-col gap-1.5 w-24">
<label className="text-[10px] text-neutral-400 font-semibold uppercase tracking-wider">Opacidad</label>
<input
type="number"
step="0.1"
min="0"
max="1"
value={selectedElement.opacity ?? 1}
onChange={(e) => handleUpdateSelected({ opacity: Number(e.target.value) })}
className="w-full bg-[#1A1A1A] text-sm text-white px-3 py-1.5 rounded-lg border border-neutral-800 focus:outline-none focus:border-neutral-600 transition-colors"
/>
</div>
</>
)}
</div>
)}
</>
) : undefined}
>
<Player
ref={playerRef}
component={BrandComposition}
inputProps={{
...compositionProps,
onElementClick: (id) => {
setShowContextMenu(false);
if (editingTextId !== id) {
setEditingTextId(null);
}
if (compositionProps.onElementClick) {
compositionProps.onElementClick(id);
}
},
onElementContextMenu: (id) => {
setSelectedElementId(id);
setShowContextMenu(true);
},
onElementDoubleClick: (id) => {
const element = timelineElements?.find(el => el.id === id);
if (element?.type === 'text') {
setSelectedElementId(id);
setEditingTextId(id);
setShowContextMenu(false);
}
}
}}
durationInFrames={durationInFrames}
compositionWidth={dimensions.width}
compositionHeight={dimensions.height}
fps={30}
controls={outputFormat !== 'image' && showControls}
clickToPlay={false}
style={{
width: '100%',
height: '100%',
borderRadius: '4px',
pointerEvents: isSpacePressed || isPanning ? 'none' : 'auto',
boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
}}
/>
{/* Safe Area Overlay */}
{safeAreaPlatform && SAFE_AREAS[safeAreaPlatform] && (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 40, borderRadius: '4px', overflow: 'hidden' }}>
{/* Top danger zone */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: `${SAFE_AREAS[safeAreaPlatform].top}%`, background: 'rgba(245, 158, 11, 0.15)', borderBottom: '1px dashed rgba(245, 158, 11, 0.6)' }} />
{/* Bottom danger zone */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${SAFE_AREAS[safeAreaPlatform].bottom}%`, background: 'rgba(245, 158, 11, 0.15)', borderTop: '1px dashed rgba(245, 158, 11, 0.6)' }} />
{/* Left danger zone */}
<div style={{ position: 'absolute', top: `${SAFE_AREAS[safeAreaPlatform].top}%`, bottom: `${SAFE_AREAS[safeAreaPlatform].bottom}%`, left: 0, width: `${SAFE_AREAS[safeAreaPlatform].left}%`, background: 'rgba(245, 158, 11, 0.08)' }} />
{/* Right danger zone */}
<div style={{ position: 'absolute', top: `${SAFE_AREAS[safeAreaPlatform].top}%`, bottom: `${SAFE_AREAS[safeAreaPlatform].bottom}%`, right: 0, width: `${SAFE_AREAS[safeAreaPlatform].right}%`, background: 'rgba(245, 158, 11, 0.08)' }} />
{/* Label */}
<div style={{ position: 'absolute', top: 4, left: '50%', transform: 'translateX(-50%)', background: 'rgba(245, 158, 11, 0.9)', color: '#000', fontSize: 9, fontWeight: 600, padding: '1px 6px', borderRadius: 4 }}>
{SAFE_AREAS[safeAreaPlatform].label} Safe Area
</div>
</div>
)}
</CanvasWorkspace>
</div>
</div>
</main>
);
};
+183
View File
@@ -0,0 +1,183 @@
import React, { useState } from 'react';
import { LayoutTemplate, Menu, Home, Settings, Download, ZoomIn, ZoomOut, X, CalendarDays, Sparkles, Play } from 'lucide-react';
interface TopHeaderProps {
currentStep: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
setCurrentStep: (step: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form') => void;
/** Open Express editor with blank canvas, no brand */
onStartExpressBlank?: () => void;
/** Open Pro editor with blank canvas, no brand */
onStartProBlank?: () => void;
outputFormat?: 'video' | 'image';
/** Zoom controls — passed up from StudioWorkspace */
zoom?: number;
onZoomIn?: () => void;
onZoomOut?: () => void;
onZoomReset?: () => void;
/** Aspect ratio controls */
aspectRatio?: '16:9' | '1:1' | '9:16' | '4:5' | '4:3';
onAspectRatioChange?: (ratio: '16:9' | '1:1' | '9:16' | '4:5' | '4:3') => void;
}
export const TopHeader: React.FC<TopHeaderProps> = ({
currentStep,
setCurrentStep,
outputFormat,
onStartExpressBlank,
onStartProBlank,
zoom = 1,
onZoomIn,
onZoomOut,
onZoomReset,
aspectRatio = '9:16',
onAspectRatioChange,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
const isStudio = currentStep === 'studio';
return (
<header className="flex-none border-b border-neutral-800/60 bg-neutral-900/95 backdrop-blur-sm px-3 h-11 flex items-center justify-between z-30 relative">
{/* Left: Hamburger + Logo */}
<div className="flex items-center gap-2">
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
title="Menú principal"
className="p-1.5 rounded-md text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
>
<Menu size={16} />
</button>
{/* Dropdown menu */}
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute top-full left-0 mt-1 w-52 bg-neutral-800 border border-neutral-700 rounded-lg shadow-2xl z-50 py-1 animate-in">
<button
onClick={() => { setCurrentStep('dashboard'); setMenuOpen(false); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<Home size={14} /> Ir al Dashboard
</button>
{currentStep !== 'brand' && (
<button
onClick={() => { setCurrentStep('brand'); setMenuOpen(false); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<Settings size={14} /> Editar Marca
</button>
)}
<button
onClick={() => { setCurrentStep('content-grid'); setMenuOpen(false); }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<CalendarDays size={14} /> Malla de Contenidos
</button>
<div className="h-px bg-neutral-700 my-1" />
<button
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
>
<Download size={14} /> Descargar
</button>
</div>
</>
)}
</div>
<div className="flex items-center gap-2">
<div className="bg-violet-600/20 p-1 rounded text-violet-400">
<LayoutTemplate size={14} />
</div>
<span className="text-xs font-semibold text-white tracking-tight">SaaS Branding</span>
</div>
</div>
{/* Center: Zoom controls (only in studio) */}
{isStudio && onZoomIn && onZoomOut && (
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); onZoomOut(); }}
title="Zoom Out"
className="p-1 text-neutral-500 hover:text-white rounded transition-colors"
>
<ZoomOut size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onZoomReset?.(); }}
title="Restablecer zoom"
className="text-[11px] font-mono text-neutral-400 hover:text-white w-12 text-center rounded py-0.5 hover:bg-neutral-800 transition-colors"
>
{Math.round(zoom * 100)}%
</button>
<button
onClick={(e) => { e.stopPropagation(); onZoomIn(); }}
title="Zoom In"
className="p-1 text-neutral-500 hover:text-white rounded transition-colors"
>
<ZoomIn size={14} />
</button>
{/* Aspect ratio pills */}
{onAspectRatioChange && (
<>
<div className="w-px h-4 bg-neutral-700 mx-1" />
{(['16:9', '9:16', '1:1', '4:5', '4:3'] as const).map(ratio => (
<button
key={ratio}
onClick={(e) => { e.stopPropagation(); onAspectRatioChange(ratio); }}
title={`Formato ${ratio}`}
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors ${
aspectRatio === ratio
? 'bg-neutral-700 text-white'
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
}`}
>
{ratio}
</button>
))}
</>
)}
</div>
)}
{/* Right: Editor buttons + Format badge */}
<div className="flex items-center gap-2">
{/* Express / Pro buttons — only on dashboard */}
{currentStep === 'dashboard' && onStartExpressBlank && (
<button
onClick={onStartExpressBlank}
title="Crear desde cero con el editor rápido (sin marca)"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-violet-600/80 to-fuchsia-600/80 hover:from-violet-500 hover:to-fuchsia-500 text-white text-[10px] font-bold transition-all shadow-sm hover:shadow-md"
>
<Sparkles size={12} />
Express
</button>
)}
{currentStep === 'dashboard' && onStartProBlank && (
<button
onClick={onStartProBlank}
title="Crear desde cero con el editor profesional (sin marca)"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-white text-[10px] font-semibold transition-all"
>
<Play size={12} />
Editor Pro 🎛
</button>
)}
{isStudio && (
<span className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">
{outputFormat === 'image' ? 'Imagen' : 'Video'}
</span>
)}
{currentStep !== 'dashboard' && !isStudio && (
<button
onClick={() => setCurrentStep('dashboard')}
className="flex items-center gap-1.5 px-3 py-1 bg-neutral-800 hover:bg-neutral-700 text-white rounded-md text-xs font-medium transition-all"
>
Dashboard
</button>
)}
</div>
</header>
);
};
+144
View File
@@ -0,0 +1,144 @@
import React from 'react';
import { Building2 } from 'lucide-react';
import { DesignMD } from '../../types';
interface BrandCardProps {
designMD: DesignMD;
width?: number;
height?: number;
className?: string;
/** Scale transform applied to the card */
scale?: number;
}
/**
* Reusable brand preview card that renders a mock frame
* showing how the DesignMD looks. Used in Dashboard and BrandPreview.
* All typography sizes scale proportionally to the card dimensions.
*/
export const BrandCard: React.FC<BrandCardProps> = ({
designMD,
width = 320,
height = 480,
className = '',
scale = 1,
}) => {
// Scale factor relative to a 1080-wide composition
const sf = width / 1080;
const pad = Math.max(12, Math.round(24 * sf * 4));
const titleFontSize = Math.round(Math.min(designMD.titleSize || 64, 64) * sf * 2.2);
const subtitleFontSize = Math.round(Math.min(designMD.subtitleSize || 32, 32) * sf * 2.2);
const paragraphFontSize = Math.max(8, Math.round(Math.min(designMD.paragraphSize || 16, 16) * sf * 2.2));
const logoWidth = Math.max(32, Math.round(120 * sf * 2));
return (
<div
className={`relative shadow-2xl transition-all duration-500 ease-out flex flex-col overflow-hidden ${className}`}
style={{
width,
height,
backgroundColor: designMD.secondaryColor,
border: `${Math.max(1, Math.round(designMD.frameThickness * sf * 2))}px solid ${designMD.primaryColor}`,
borderRadius: Math.max(8, Math.round(16 * sf * 2)),
padding: pad,
transform: `scale(${scale})`,
transformOrigin: 'center center'
}}
>
{/* Logo */}
<div className="shrink-0">
{designMD.logoUrl ? (
<img
src={designMD.logoUrl}
alt="Logo"
style={{ width: logoWidth, maxHeight: logoWidth * 0.6, objectFit: 'contain' }}
className="filter drop-shadow-md"
/>
) : (
<div className="flex items-center gap-1.5 opacity-30">
<Building2 size={Math.max(12, logoWidth * 0.3)} style={{ color: designMD.textColor }} />
</div>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Typography Preview */}
<div
className="shrink-0 text-center border border-white/5"
style={{
backgroundColor: 'rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
padding: `${Math.max(8, pad * 0.8)}px ${Math.max(6, pad * 0.6)}px`,
borderRadius: Math.max(6, Math.round(16 * sf * 2)),
}}
>
<h1
style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.titleColor || designMD.textColor,
fontSize: titleFontSize,
lineHeight: 1.15,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
className="font-bold tracking-tight"
>
Título Principal
</h1>
<h2
style={{
fontFamily: designMD.subtitleFont || designMD.baseFont,
color: designMD.subtitleColor || designMD.textColor,
fontSize: subtitleFontSize,
lineHeight: 1.25,
marginTop: Math.max(2, Math.round(8 * sf * 2)),
}}
className="font-medium opacity-90"
>
Subtítulo de marca
</h2>
<p
style={{
fontFamily: designMD.paragraphFont || designMD.baseFont,
color: designMD.paragraphColor || designMD.textColor,
fontSize: paragraphFontSize,
lineHeight: 1.5,
marginTop: Math.max(2, Math.round(6 * sf * 2)),
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
className="opacity-70 font-light"
>
Este es un párrafo de ejemplo que muestra el estilo del texto extendido.
</p>
</div>
{/* Color palette strip */}
<div className="flex gap-1 mt-2 justify-center shrink-0">
<div
className="rounded-full"
style={{ width: Math.max(6, 10 * sf * 2), height: Math.max(6, 10 * sf * 2), backgroundColor: designMD.primaryColor }}
/>
<div
className="rounded-full border border-white/10"
style={{ width: Math.max(6, 10 * sf * 2), height: Math.max(6, 10 * sf * 2), backgroundColor: designMD.secondaryColor }}
/>
<div
className="rounded-full"
style={{ width: Math.max(6, 10 * sf * 2), height: Math.max(6, 10 * sf * 2), backgroundColor: designMD.textColor }}
/>
</div>
</div>
);
};
+203
View File
@@ -0,0 +1,203 @@
import React, { useState } from 'react';
import { Settings2, ZoomIn, ZoomOut, Play, BarChart3, Monitor, Square, Smartphone } from 'lucide-react';
import { DesignMD, CompanyProfile } from '../../types';
import { BrandCard } from './BrandCard';
import { PreviewCompanyCard } from './previews/PreviewCompanyCard';
import { PreviewTypography } from './previews/PreviewTypography';
import { PreviewTimeline } from './previews/PreviewTimeline';
import { PreviewRemotion } from './previews/PreviewRemotion';
type BrandTab = 'general' | 'visual' | 'typography' | 'media';
type AspectRatio = '16:9' | '1:1' | '9:16';
interface BrandPreviewProps {
designMD: DesignMD;
company: CompanyProfile;
activeTab: BrandTab;
zoom: number;
setZoom: React.Dispatch<React.SetStateAction<number>>;
aspectRatio: AspectRatio;
setAspectRatio: (ratio: AspectRatio) => void;
handleDesignChange?: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
const TAB_SUBTITLES: Record<BrandTab, string> = {
general: 'Así se presenta tu empresa',
visual: 'Así luce el sistema de diseño estricto',
typography: 'Jerarquía tipográfica de tu marca',
media: 'Preview en vivo del resultado final',
};
const RATIO_ICONS: Record<AspectRatio, React.ReactNode> = {
'16:9': <Monitor size={13} />,
'1:1': <Square size={13} />,
'9:16': <Smartphone size={13} />,
};
const RATIO_LABELS: Record<AspectRatio, string> = {
'16:9': 'Landscape',
'1:1': 'Cuadrado',
'9:16': 'Vertical',
};
export const BrandPreview: React.FC<BrandPreviewProps> = ({
designMD,
company,
activeTab,
zoom,
setZoom,
aspectRatio,
setAspectRatio,
handleDesignChange,
}) => {
const showZoomControls = activeTab === 'visual';
const showAspectRatio = activeTab === 'visual' || activeTab === 'media';
const [mediaView, setMediaView] = useState<'player' | 'info'>('player');
const getDimensions = () => {
if (aspectRatio === '16:9') return { width: 480, height: 270 };
if (aspectRatio === '1:1') return { width: 360, height: 360 };
return { width: 320, height: 480 }; // 9:16
};
const dimensions = getDimensions();
return (
<div className="w-1/2 bg-neutral-950 p-8 flex flex-col relative overflow-hidden">
{/* Header */}
<div className="flex items-start justify-between mb-6 shrink-0">
<div>
<p className="text-xs uppercase tracking-widest text-neutral-600 font-bold mb-1">Previsualización</p>
<p className="text-sm text-neutral-400">{TAB_SUBTITLES[activeTab]}</p>
</div>
<div className="flex items-center gap-2">
{/* Aspect Ratio selector — for visual & media */}
{showAspectRatio && (
<div className="flex bg-neutral-900 rounded-lg p-1 border border-neutral-800 shadow-xl text-sm items-center">
{(['16:9', '1:1', '9:16'] as const).map(ratio => (
<button
key={ratio}
onClick={() => setAspectRatio(ratio)}
title={`${RATIO_LABELS[ratio]} (${ratio})`}
className={`px-2.5 py-1.5 rounded-md transition-colors flex items-center gap-1.5 ${
aspectRatio === ratio
? 'bg-neutral-800 text-white font-medium shadow-sm'
: 'text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800/50'
}`}
>
{RATIO_ICONS[ratio]}
<span className="text-xs">{ratio}</span>
</button>
))}
</div>
)}
{/* Zoom controls — visual tab only */}
{showZoomControls && (
<div className="flex bg-neutral-900 rounded-lg p-1 border border-neutral-800 shadow-xl text-sm items-center">
<button
onClick={() => setZoom(1)}
title="Restablecer Vista"
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50 rounded-md transition-colors"
>
<Settings2 size={16} />
</button>
<div className="w-[1px] h-4 bg-neutral-800 mx-1"></div>
<button
onClick={() => setZoom(prev => Math.max(0.25, prev - 0.25))}
title="Zoom Out"
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50 rounded-md transition-colors"
>
<ZoomOut size={16} />
</button>
<span className="text-neutral-400 text-xs w-10 text-center font-mono">
{Math.round(zoom * 100)}%
</span>
<button
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
title="Zoom In"
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50 rounded-md transition-colors"
>
<ZoomIn size={16} />
</button>
</div>
)}
{/* Media tab: toggle between player and info */}
{activeTab === 'media' && (
<div className="flex bg-neutral-900 rounded-lg p-1 border border-neutral-800 shadow-xl text-sm items-center">
<button
onClick={() => setMediaView('player')}
title="Vista de video en vivo"
className={`p-1.5 px-3 rounded-md transition-colors flex items-center gap-1.5 ${
mediaView === 'player'
? 'bg-neutral-800 text-white font-medium shadow-sm'
: 'text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50'
}`}
>
<Play size={14} />
<span className="text-xs">Video</span>
</button>
<button
onClick={() => setMediaView('info')}
title="Vista de estructura"
className={`p-1.5 px-3 rounded-md transition-colors flex items-center gap-1.5 ${
mediaView === 'info'
? 'bg-neutral-800 text-white font-medium shadow-sm'
: 'text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800/50'
}`}
>
<BarChart3 size={14} />
<span className="text-xs">Estructura</span>
</button>
</div>
)}
</div>
</div>
{/* Preview Content — full remaining space */}
<div className="flex-1 flex items-center justify-center overflow-auto min-h-0">
{activeTab === 'general' && (
<PreviewCompanyCard company={company} designMD={designMD} />
)}
{activeTab === 'visual' && (
<BrandCard
designMD={designMD}
width={dimensions.width}
height={dimensions.height}
scale={zoom}
/>
)}
{activeTab === 'typography' && (
<div className="flex items-center gap-6 max-h-full overflow-auto">
<PreviewTypography designMD={designMD} />
</div>
)}
{activeTab === 'media' && mediaView === 'player' && (
<div className="flex flex-col h-full w-full min-h-0">
<div className="flex-1 min-h-0">
<PreviewRemotion
designMD={designMD}
company={company}
aspectRatio={aspectRatio}
onDesignChange={handleDesignChange}
/>
</div>
</div>
)}
{activeTab === 'media' && mediaView === 'info' && (
<div className="overflow-auto max-h-full w-full max-w-md mx-auto">
<PreviewTimeline designMD={designMD} aspectRatio={aspectRatio} />
</div>
)}
</div>
</div>
);
};
+136
View File
@@ -0,0 +1,136 @@
import React from 'react';
import { Link2, Instagram, AtSign, Play, Globe } from 'lucide-react';
import { CompanyProfile } from '../../types';
const INDUSTRIES = [
'Tecnología',
'Moda y Lifestyle',
'Salud y Bienestar',
'Educación',
'Restaurante y Food',
'Fitness y Deporte',
'Finanzas',
'Entretenimiento',
'E-commerce',
'Otro'
];
interface BrandTabGeneralProps {
company: CompanyProfile;
handleCompanyChange: (company: CompanyProfile) => void;
}
export const BrandTabGeneral: React.FC<BrandTabGeneralProps> = ({ company, handleCompanyChange }) => {
return (
<div className="space-y-6">
{/* Company Details */}
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase">Información de la Marca</h3>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2">Nombre de la Empresa</label>
<input
type="text"
value={company?.name || ''}
onChange={(e) => handleCompanyChange({ ...company, name: e.target.value })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all font-medium"
placeholder="Ej. TechFlow"
/>
</div>
{/* Tagline */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2">
Tagline / Eslogan
<span className="text-xs text-neutral-500 font-normal">(opcional)</span>
</label>
<input
type="text"
value={company?.tagline || ''}
onChange={(e) => handleCompanyChange({ ...company, tagline: e.target.value })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="Ej. Innovación que transforma"
maxLength={80}
/>
</div>
{/* Industry */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-2 flex items-center gap-2">
Industria
<span className="text-xs text-neutral-500 font-normal">(opcional)</span>
</label>
<div className="grid grid-cols-2 gap-2">
{INDUSTRIES.map(ind => (
<button
key={ind}
type="button"
onClick={() => handleCompanyChange({
...company,
industry: company.industry === ind ? undefined : ind
})}
className={`px-3 py-2 text-xs font-medium rounded-lg border transition-all text-left ${
company.industry === ind
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-950 border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-300'
}`}
>
{ind}
</button>
))}
</div>
</div>
</div>
</div>
{/* Social Links */}
<div className="space-y-4 pt-4 border-t border-neutral-800">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase flex items-center gap-2">
<Globe size={16} /> Redes y Presencia
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><Link2 size={14} /> Website</label>
<input
type="text"
value={company?.socialLinks?.website || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, website: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="https://"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><Instagram size={14} /> Instagram</label>
<input
type="text"
value={company?.socialLinks?.instagram || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, instagram: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="@usuario"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><AtSign size={14} /> TikTok</label>
<input
type="text"
value={company?.socialLinks?.tiktok || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, tiktok: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="@usuario"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5 flex items-center gap-2"><Play size={14} /> YouTube</label>
<input
type="text"
value={company?.socialLinks?.youtube || ''}
onChange={(e) => handleCompanyChange({ ...company, socialLinks: { ...company.socialLinks, youtube: e.target.value } })}
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 transition-all text-sm"
placeholder="@canal"
/>
</div>
</div>
</div>
</div>
);
};
+258
View File
@@ -0,0 +1,258 @@
import React, { useCallback } from 'react';
import { Film, Volume2, Music, X, Upload } from 'lucide-react';
import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
interface BrandTabMediaProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
/**
* BrandTabMedia Upload-only panel for brand video/audio assets.
*
* Only handles uploading the intro video, outro video, and brand audio.
* All positioning, fit, duration, and blend controls live in the TemplateBuilder
* (per-template segment configuration), avoiding collisions.
*/
export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDesignChange }) => {
/** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
if (!url) return;
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (video.duration && isFinite(video.duration)) {
const frames = Math.round(video.duration * 30); // 30fps
handleDesignChange(key, Math.max(15, Math.min(300, frames)));
}
video.remove();
};
video.onerror = () => video.remove();
video.src = url;
}, [handleDesignChange]);
return (
<div className="space-y-5">
{/* Section title */}
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Film size={16} className="text-violet-400" />
Archivos de Video y Audio
</h3>
<p className="text-xs text-neutral-500 leading-relaxed">
Sube los videos y audio de tu marca. La posición, duración y estilo se configuran en cada plantilla.
</p>
</div>
{/* ═══ Intro Video ═══ */}
<VideoUploadSimple
label="Video de Cabezote (Intro)"
description="Se usará automáticamente en plantillas que incluyan segmento de intro de marca"
videoUrl={designMD.introVideoUrl || ''}
accentColor="#10b981"
onUrlChange={(url) => {
handleDesignChange('introVideoUrl', url);
if (url) probeVideoDuration(url, 'introDurationFrames');
}}
onClear={() => {
handleDesignChange('introVideoUrl', '');
handleDesignChange('introDurationFrames', 60);
}}
/>
{/* ═══ Outro Video ═══ */}
<VideoUploadSimple
label="Video de Cierre (Outro)"
description="Se usará automáticamente en plantillas que incluyan segmento de outro de marca"
videoUrl={designMD.outroVideoUrl || ''}
accentColor="#f43f5e"
onUrlChange={(url) => {
handleDesignChange('outroVideoUrl', url);
if (url) probeVideoDuration(url, 'outroDurationFrames');
}}
onClear={() => {
handleDesignChange('outroVideoUrl', '');
handleDesignChange('outroDurationFrames', 60);
}}
/>
{/* ═══ Brand Audio ═══ */}
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Música / Jingle de Marca
</label>
{designMD.brandAudioUrl && (
<button
onClick={() => handleDesignChange('brandAudioUrl', '')}
title="Quitar audio de marca"
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">
Se incluirá como pista de fondo en plantillas de video
</p>
<div className="flex gap-3 items-start">
{/* Preview */}
<div className="w-14 h-14 rounded-lg bg-neutral-950 border border-neutral-800 flex items-center justify-center shrink-0">
{designMD.brandAudioUrl ? (
<div className="flex items-end gap-0.5 h-6">
{[3, 5, 4, 6, 3].map((h, i) => (
<div
key={i}
className="w-1 bg-violet-500 rounded-full animate-pulse"
style={{ height: `${h * 3}px`, animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
) : (
<Music size={20} className="text-neutral-600" />
)}
</div>
{/* Upload controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={designMD.brandAudioUrl || ''}
onChange={(e) => handleDesignChange('brandAudioUrl', e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://audio.mp3"
/>
<FileDropZone
compact
accept="audio/*"
label="Subir audio"
onFiles={(files) => {
const url = URL.createObjectURL(files[0]);
handleDesignChange('brandAudioUrl', url);
}}
/>
</div>
</div>
{/* Volume slider */}
{designMD.brandAudioUrl && (
<div className="flex items-center gap-3 pt-1">
<Volume2 size={12} className="text-neutral-500 shrink-0" />
<span className="text-[10px] text-neutral-500 shrink-0">Volumen:</span>
<input
type="range"
min="0"
max="100"
value={Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}
onChange={(e) => handleDesignChange('brandAudioVolume', parseInt(e.target.value) / 100)}
className="flex-1 h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
/>
<span className="text-[10px] font-mono text-violet-300 bg-neutral-800 px-1.5 py-0.5 rounded shrink-0">
{Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}%
</span>
</div>
)}
</div>
</div>
);
};
/* ── Simple Video Upload Card ── */
const VideoUploadSimple: React.FC<{
label: string;
description: string;
videoUrl: string;
accentColor: string;
onUrlChange: (url: string) => void;
onClear: () => void;
}> = ({ label, description, videoUrl, accentColor, onUrlChange, onClear }) => {
const hasVideo = !!videoUrl && videoUrl.trim().length > 0;
return (
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Film size={14} style={{ color: accentColor }} />
{label}
</label>
{hasVideo && (
<button
onClick={onClear}
title={`Quitar ${label}`}
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">{description}</p>
<div className="flex gap-3 items-start">
{/* Video Preview */}
<div className="w-28 h-20 rounded-lg overflow-hidden bg-neutral-950 border border-neutral-800 shrink-0 flex items-center justify-center">
{hasVideo ? (
<video
src={videoUrl}
className="w-full h-full object-cover"
muted
playsInline
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const v = e.target as HTMLVideoElement;
v.pause();
v.currentTime = 0;
}}
/>
) : (
<div className="text-neutral-600 flex flex-col items-center gap-1">
<Upload size={18} style={{ color: `${accentColor}60` }} />
<span className="text-[9px]">Sin video</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={videoUrl}
onChange={(e) => onUrlChange(e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://video.mp4"
/>
<FileDropZone
compact
accept="video/*"
label="Subir archivo"
onFiles={(files) => {
const url = URL.createObjectURL(files[0]);
onUrlChange(url);
}}
/>
</div>
</div>
{/* Status badge */}
{hasVideo && (
<div
className="flex items-center gap-1.5 text-[10px] font-medium px-2.5 py-1 rounded-lg w-fit"
style={{
backgroundColor: `${accentColor}15`,
color: accentColor,
border: `1px solid ${accentColor}30`,
}}
>
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
Video cargado
</div>
)}
</div>
);
};
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
import { Type } from 'lucide-react';
import { DesignMD } from '../../types';
import { FontPicker } from '../ui/FontPicker';
interface BrandTabTypographyProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
export const BrandTabTypography: React.FC<BrandTabTypographyProps> = ({ designMD, handleDesignChange }) => {
return (
<div className="space-y-6">
{/* Sistema Tipográfico Jerárquico */}
<div className="space-y-4">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase flex items-center gap-2"><Type size={16} /> Sistema Tipográfico</h3>
{/* Fuente Base */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Fuente Base</label>
<p className="text-[10px] text-neutral-500 -mt-1">Se usa como fallback cuando un rol tipográfico no tiene fuente asignada</p>
<FontPicker
value={designMD.baseFont}
onChange={(font) => handleDesignChange('baseFont', font)}
/>
</div>
{/* Títulos */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Estilo para Títulos</label>
<div className="grid grid-cols-[2fr_1fr_1fr] gap-3">
<FontPicker
value={designMD.titleFont || designMD.baseFont}
onChange={(font) => handleDesignChange('titleFont', font)}
brandFont={designMD.baseFont}
/>
<input
type="number"
value={designMD.titleSize || 64}
onChange={(e) => handleDesignChange('titleSize', Number(e.target.value))}
className="bg-neutral-900 text-sm rounded-lg px-3 py-2 border border-neutral-800 outline-none font-mono text-white"
placeholder="Size"
/>
<div className="flex items-center bg-neutral-900 border border-neutral-800 rounded-lg px-2">
<input
type="color"
value={designMD.titleColor || designMD.textColor}
onChange={(e) => handleDesignChange('titleColor', e.target.value)}
className="w-6 h-6 rounded bg-neutral-800 border-none cursor-pointer"
/>
</div>
</div>
</div>
{/* Subtítulos */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Estilo para Subtítulos</label>
<div className="grid grid-cols-[2fr_1fr_1fr] gap-3">
<FontPicker
value={designMD.subtitleFont || designMD.baseFont}
onChange={(font) => handleDesignChange('subtitleFont', font)}
brandFont={designMD.baseFont}
/>
<input
type="number"
value={designMD.subtitleSize || 32}
onChange={(e) => handleDesignChange('subtitleSize', Number(e.target.value))}
className="bg-neutral-900 text-sm rounded-lg px-3 py-2 border border-neutral-800 outline-none font-mono text-white"
placeholder="Size"
/>
<div className="flex items-center bg-neutral-900 border border-neutral-800 rounded-lg px-2">
<input
type="color"
value={designMD.subtitleColor || designMD.textColor}
onChange={(e) => handleDesignChange('subtitleColor', e.target.value)}
className="w-6 h-6 rounded bg-neutral-800 border-none cursor-pointer"
/>
</div>
</div>
</div>
{/* Párrafos */}
<div className="bg-neutral-900/50 border border-neutral-800 p-4 rounded-xl space-y-3">
<label className="block text-xs font-semibold text-neutral-400">Estilo para Párrafos</label>
<div className="grid grid-cols-[2fr_1fr_1fr] gap-3">
<FontPicker
value={designMD.paragraphFont || designMD.baseFont}
onChange={(font) => handleDesignChange('paragraphFont', font)}
brandFont={designMD.baseFont}
/>
<input
type="number"
value={designMD.paragraphSize || 16}
onChange={(e) => handleDesignChange('paragraphSize', Number(e.target.value))}
className="bg-neutral-900 text-sm rounded-lg px-3 py-2 border border-neutral-800 outline-none font-mono text-white"
placeholder="Size"
/>
<div className="flex items-center bg-neutral-900 border border-neutral-800 rounded-lg px-2">
<input
type="color"
value={designMD.paragraphColor || designMD.textColor}
onChange={(e) => handleDesignChange('paragraphColor', e.target.value)}
className="w-6 h-6 rounded bg-neutral-800 border-none cursor-pointer"
/>
</div>
</div>
</div>
</div>
</div>
);
};
+137
View File
@@ -0,0 +1,137 @@
import React, { useCallback } from 'react';
import { Settings2, ImageIcon } from 'lucide-react';
import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
interface BrandTabVisualProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
}
export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
designMD,
handleDesignChange,
}) => {
const handleLogoFiles = useCallback((files: File[]) => {
const file = files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
handleDesignChange('logoUrl', event.target.result as string);
}
};
reader.readAsDataURL(file);
}, [handleDesignChange]);
return (
<div className="space-y-6">
{/* Logo */}
<div className="space-y-2">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase">Identidad Visual</h3>
<label className="block text-sm font-medium text-neutral-300 mb-2">Logo Corporativo</label>
<div className="flex gap-4 items-start">
<div className="w-24 h-24 rounded-xl bg-white flex items-center justify-center p-3 shrink-0 border border-neutral-700 shadow-lg">
{designMD.logoUrl ? (
<img src={designMD.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
) : (
<ImageIcon size={32} className="text-neutral-300" />
)}
</div>
<div className="flex-1 space-y-2">
<input
type="text"
value={designMD.logoUrl}
onChange={(e) => handleDesignChange('logoUrl', e.target.value)}
className="bg-neutral-900 text-sm rounded-lg px-4 py-2.5 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://logo.svg"
/>
<FileDropZone
compact
accept="image/png, image/jpeg, image/svg+xml, image/webp"
label="Subir desde archivo"
onFiles={handleLogoFiles}
/>
</div>
</div>
</div>
{/* Colors Grid */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-xl">
<label className="block text-xs font-medium text-neutral-400 mb-3">Color Primario (Marco)</label>
<div className="flex items-center gap-3">
<input
type="color"
value={designMD.primaryColor}
onChange={(e) => handleDesignChange('primaryColor', e.target.value)}
className="w-10 h-10 rounded-lg shrink-0 bg-neutral-800 border-none cursor-pointer"
/>
<input
type="text"
value={designMD.primaryColor}
onChange={(e) => handleDesignChange('primaryColor', e.target.value)}
className="bg-neutral-950 text-sm rounded uppercase px-3 py-2 w-full border border-neutral-800 outline-none font-mono text-neutral-300"
/>
</div>
</div>
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-xl">
<label className="block text-xs font-medium text-neutral-400 mb-3">Color Secundario (Fondo)</label>
<div className="flex items-center gap-3">
<input
type="color"
value={designMD.secondaryColor}
onChange={(e) => handleDesignChange('secondaryColor', e.target.value)}
className="w-10 h-10 rounded-lg shrink-0 bg-neutral-800 border-none cursor-pointer"
/>
<input
type="text"
value={designMD.secondaryColor}
onChange={(e) => handleDesignChange('secondaryColor', e.target.value)}
className="bg-neutral-950 text-sm rounded uppercase px-3 py-2 w-full border border-neutral-800 outline-none font-mono text-neutral-300"
/>
</div>
</div>
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-xl">
<label className="block text-xs font-medium text-neutral-400 mb-3">Color de Texto Base</label>
<div className="flex items-center gap-3">
<input
type="color"
value={designMD.textColor}
onChange={(e) => handleDesignChange('textColor', e.target.value)}
className="w-10 h-10 rounded-lg shrink-0 bg-neutral-800 border-none cursor-pointer"
/>
<input
type="text"
value={designMD.textColor}
onChange={(e) => handleDesignChange('textColor', e.target.value)}
className="bg-neutral-950 text-sm rounded uppercase px-3 py-2 w-full border border-neutral-800 outline-none font-mono text-neutral-300"
/>
</div>
</div>
</div>
{/* Frame Thickness */}
<div className="space-y-4 pt-4 border-t border-neutral-800">
<h3 className="text-sm font-semibold tracking-widest text-neutral-500 uppercase flex items-center gap-2"><Settings2 size={16} /> Configuración Base</h3>
<div>
<label className="flex justify-between text-sm font-medium text-neutral-300 mb-4">
<span>Espesor del Marco Perimetral</span>
<span className="bg-neutral-800 px-2 py-0.5 rounded text-violet-300 text-xs font-mono">{designMD.frameThickness}px</span>
</label>
<input
type="range"
min="0"
max="80"
value={designMD.frameThickness}
onChange={(e) => handleDesignChange('frameThickness', parseInt(e.target.value))}
className="w-full h-2 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
/>
</div>
</div>
</div>
);
};
+144
View File
@@ -0,0 +1,144 @@
import React, { useState, useRef, useEffect } from 'react';
import { X, Briefcase, Sparkles } from 'lucide-react';
const INDUSTRIES = [
'Tecnología',
'Moda y Lifestyle',
'Salud y Bienestar',
'Educación',
'Restaurante y Food',
'Fitness y Deporte',
'Finanzas',
'Entretenimiento',
'E-commerce',
'Otro'
];
interface CreateBrandModalProps {
onConfirm: (name: string, industry?: string) => void;
onCancel: () => void;
}
export const CreateBrandModal: React.FC<CreateBrandModalProps> = ({ onConfirm, onCancel }) => {
const [name, setName] = useState('');
const [industry, setIndustry] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const isValid = name.trim().length >= 2;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isValid) {
onConfirm(name.trim(), industry || undefined);
}
};
return (
<div className="fixed inset-0 z-[999] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Modal */}
<div className="relative bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden animate-in">
{/* Header */}
<div className="flex items-center justify-between p-6 pb-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-violet-600/20 border border-violet-500/30 flex items-center justify-center">
<Briefcase size={18} className="text-violet-400" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Nueva Marca</h2>
<p className="text-xs text-neutral-400">Define la identidad de tu empresa</p>
</div>
</div>
<button
onClick={onCancel}
title="Cerrar"
className="text-neutral-500 hover:text-white p-1.5 rounded-lg hover:bg-neutral-800 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Name */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-2">
Nombre de la Marca <span className="text-rose-400">*</span>
</label>
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className={`w-full bg-neutral-950 border rounded-xl px-4 py-3 text-white text-lg font-medium focus:outline-none focus:ring-2 transition-all placeholder:text-neutral-600 ${
name.length > 0 && !isValid
? 'border-rose-500/50 focus:ring-rose-500/30'
: 'border-neutral-800 focus:ring-violet-500/30 focus:border-violet-500/50'
}`}
placeholder="Ej. TechFlow, Neon Fashion..."
maxLength={50}
/>
{name.length > 0 && !isValid && (
<p className="text-xs text-rose-400 mt-1.5">El nombre debe tener al menos 2 caracteres</p>
)}
</div>
{/* Industry */}
<div>
<label className="block text-sm font-medium text-neutral-300 mb-2">
Industria <span className="text-neutral-500">(opcional)</span>
</label>
<div className="grid grid-cols-2 gap-2">
{INDUSTRIES.map(ind => (
<button
key={ind}
type="button"
onClick={() => setIndustry(industry === ind ? '' : ind)}
className={`px-3 py-2 text-xs font-medium rounded-lg border transition-all text-left ${
industry === ind
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-950 border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-300'
}`}
>
{ind}
</button>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-3 rounded-xl border border-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors font-medium text-sm"
>
Cancelar
</button>
<button
type="submit"
disabled={!isValid}
className={`flex-1 px-4 py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all ${
isValid
? 'bg-violet-600 hover:bg-violet-500 text-white shadow-lg shadow-violet-900/30'
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed'
}`}
>
<Sparkles size={16} />
Crear Marca
</button>
</div>
</form>
</div>
</div>
);
};
+72
View File
@@ -0,0 +1,72 @@
import React, { useState } from 'react';
interface TransitionCardProps {
value: string;
label: string;
icon: string;
selected: boolean;
onSelect: () => void;
}
/**
* Compact transition selector with CSS micro-animation on hover.
*/
export const TransitionCard: React.FC<TransitionCardProps> = ({
value,
label,
icon,
selected,
onSelect,
}) => {
const [hovering, setHovering] = useState(false);
return (
<button
type="button"
onClick={onSelect}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
title={`Transición: ${label}`}
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border transition-all text-left group ${
selected
? 'bg-violet-600/20 border-violet-500/60 text-white'
: 'bg-neutral-950/50 border-neutral-800/60 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300 hover:bg-neutral-900'
}`}
>
<span
className="w-5 h-5 flex items-center justify-center text-sm shrink-0 transition-all duration-500"
style={getAnimationStyle(value, hovering)}
>
{icon}
</span>
<span className="text-[11px] font-medium leading-none truncate">{label}</span>
</button>
);
};
function getAnimationStyle(value: string, hovering: boolean): React.CSSProperties {
if (!hovering) return {};
switch (value) {
case 'fade':
return { opacity: 0, transition: 'opacity 0.4s ease-in-out', animation: 'fadeInCard 0.6s ease forwards' };
case 'slideUp':
return { transform: 'translateY(6px)', animation: 'slideUpCard 0.5s ease forwards' };
case 'slideRight':
return { transform: 'translateX(-6px)', animation: 'slideRightCard 0.5s ease forwards' };
case 'typewriter':
return { opacity: 0.3, animation: 'typewriterCard 0.6s steps(4) forwards' };
case 'bounce':
return { animation: 'bounceCard 0.6s ease forwards' };
case 'scale':
return { transform: 'scale(0.3)', animation: 'scaleCard 0.4s ease forwards' };
case 'crossfade':
return { opacity: 0.3, animation: 'fadeInCard 0.8s ease forwards' };
case 'dipToBlack':
return { opacity: 0, animation: 'dipToBlackCard 0.8s ease forwards' };
case 'flash':
return { animation: 'flashCard 0.4s ease forwards' };
default:
return {};
}
}
@@ -0,0 +1,121 @@
import React from 'react';
import { Globe, Instagram, AtSign, Play, Building2 } from 'lucide-react';
import { CompanyProfile, DesignMD } from '../../../types';
interface PreviewCompanyCardProps {
company: CompanyProfile;
designMD: DesignMD;
}
/**
* Live corporate identity card showing company data in real-time.
*/
export const PreviewCompanyCard: React.FC<PreviewCompanyCardProps> = ({ company, designMD }) => {
const socialEntries = [
{ icon: <Globe size={14} />, value: company.socialLinks?.website, label: 'Web' },
{ icon: <Instagram size={14} />, value: company.socialLinks?.instagram, label: 'Instagram' },
{ icon: <AtSign size={14} />, value: company.socialLinks?.tiktok, label: 'TikTok' },
{ icon: <Play size={14} />, value: company.socialLinks?.youtube, label: 'YouTube' },
].filter(s => s.value);
return (
<div
className="w-[340px] rounded-2xl overflow-hidden shadow-2xl transition-all duration-500"
style={{ border: `3px solid ${designMD.primaryColor}` }}
>
{/* Header band */}
<div
className="px-6 py-8 flex flex-col items-center text-center relative"
style={{ backgroundColor: designMD.primaryColor }}
>
{/* Logo */}
<div className="w-20 h-20 rounded-2xl bg-white flex items-center justify-center p-3 shadow-xl mb-4">
{designMD.logoUrl ? (
<img src={designMD.logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
) : (
<Building2 size={32} className="text-neutral-300" />
)}
</div>
{/* Name */}
<h2
className="text-xl font-bold tracking-tight"
style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.textColor,
}}
>
{company.name || 'Nombre de Marca'}
</h2>
{/* Tagline */}
{company.tagline && (
<p
className="text-sm mt-1.5 opacity-80"
style={{
fontFamily: designMD.subtitleFont || designMD.baseFont,
color: designMD.textColor,
}}
>
"{company.tagline}"
</p>
)}
</div>
{/* Body */}
<div
className="px-6 py-5 space-y-4"
style={{ backgroundColor: designMD.secondaryColor }}
>
{/* Industry Badge */}
{company.industry && (
<div className="flex justify-center">
<span
className="text-xs font-semibold px-3 py-1.5 rounded-full"
style={{
backgroundColor: `${designMD.primaryColor}30`,
color: designMD.primaryColor,
border: `1px solid ${designMD.primaryColor}40`,
}}
>
{company.industry}
</span>
</div>
)}
{/* Social Links */}
{socialEntries.length > 0 && (
<div className="space-y-2">
{socialEntries.map((entry, i) => (
<div
key={i}
className="flex items-center gap-3 px-3 py-2 rounded-lg transition-colors"
style={{
backgroundColor: `${designMD.primaryColor}10`,
}}
>
<span style={{ color: designMD.primaryColor }}>{entry.icon}</span>
<span
className="text-sm font-medium truncate"
style={{
color: designMD.textColor,
fontFamily: designMD.baseFont,
}}
>
{entry.value}
</span>
</div>
))}
</div>
)}
{/* Empty state */}
{!company.tagline && socialEntries.length === 0 && !company.industry && (
<p className="text-center text-sm opacity-50" style={{ color: designMD.textColor }}>
Completa los datos en el panel izquierdo para verlos aquí
</p>
)}
</div>
</div>
);
};
@@ -0,0 +1,771 @@
import React, { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { Player } from '@remotion/player';
import {
AbsoluteFill,
Sequence,
Video,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from 'remotion';
import { DesignMD, CompanyProfile } from '../../../types';
import { CanvasWorkspace } from '../../ui/CanvasWorkspace';
interface PreviewRemotionProps {
designMD: DesignMD;
company: CompanyProfile;
aspectRatio?: '16:9' | '1:1' | '9:16';
onDesignChange?: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
focusSegment?: 'intro' | 'content' | 'outro' | 'audio' | null;
/** Called on every frame update with the current frame number */
onFrameUpdate?: (frame: number) => void;
/** Called when player is ready, passes a seek function */
onPlayerReady?: (seekFn: (frame: number) => void) => void;
}
const COMPOSITION_DIMS: Record<string, { width: number; height: number; css: string }> = {
'16:9': { width: 1920, height: 1080, css: '16/9' },
'1:1': { width: 1080, height: 1080, css: '1/1' },
'9:16': { width: 1080, height: 1920, css: '9/16' },
};
type DragElement = 'logo' | 'content' | 'intro' | 'outro'
| 'intro-resize-br' | 'intro-resize-bl' | 'intro-resize-tr' | 'intro-resize-tl'
| 'outro-resize-br' | 'outro-resize-bl' | 'outro-resize-tr' | 'outro-resize-tl'
| null;
/** Parse a CSS object-position string to x/y percentages */
function parseVideoPosition(pos?: string): { x: number; y: number } {
if (!pos) return { x: 50, y: 50 };
if (pos.includes('%')) {
const parts = pos.split(/\s+/);
return { x: parseFloat(parts[0]) || 50, y: parseFloat(parts[1]) || 50 };
}
const map: Record<string, { x: number; y: number }> = {
'top left': { x: 0, y: 0 }, 'top center': { x: 50, y: 0 }, 'top right': { x: 100, y: 0 }, 'top': { x: 50, y: 0 },
'center left': { x: 0, y: 50 }, 'center': { x: 50, y: 50 }, 'center right': { x: 100, y: 50 },
'bottom left': { x: 0, y: 100 }, 'bottom center': { x: 50, y: 100 }, 'bottom right': { x: 100, y: 100 }, 'bottom': { x: 50, y: 100 },
'left': { x: 0, y: 50 }, 'right': { x: 100, y: 50 },
};
return map[pos] || { x: 50, y: 50 };
}
/**
* Live Remotion Player showing a sample composition with the brand's DesignMD settings.
* Supports interactive drag-to-reposition for logo and content block.
*/
export const PreviewRemotion: React.FC<PreviewRemotionProps> = ({ designMD, company, aspectRatio = '9:16', onDesignChange, focusSegment, onFrameUpdate, onPlayerReady }) => {
const hasIntro = !!designMD.introVideoUrl;
const hasOutro = !!designMD.outroVideoUrl;
const introDur = designMD.introDurationFrames || 60;
const outroDur = designMD.outroDurationFrames || 60;
const contentDur = 180;
const totalDur = (hasIntro ? introDur : 0) + contentDur + (hasOutro ? outroDur : 0);
const dims = COMPOSITION_DIMS[aspectRatio] || COMPOSITION_DIMS['9:16'];
// Compute frame ranges for each segment
const introStart = 0;
const contentStart = hasIntro ? introDur : 0;
const outroStart = contentStart + contentDur;
// Player ref for seeking
const playerRef = useRef<any>(null);
// Drag state for the overlay
const overlayRef = useRef<HTMLDivElement>(null);
const [dragElement, setDragElement] = useState<DragElement>(null);
const [dragStart, setDragStart] = useState<{ x: number; y: number; origX: number; origY: number } | null>(null);
// Current positions
const logoX = designMD.logoX ?? 10;
const logoY = designMD.logoY ?? 5;
const contentX = designMD.contentX ?? 50;
const contentY = designMD.contentY ?? 75;
// Video box positions & sizes (% of canvas)
const introX = designMD.introVideoX ?? 0;
const introY = designMD.introVideoY ?? 0;
const introW = designMD.introVideoW ?? 100;
const introH = designMD.introVideoH ?? 100;
const outroX = designMD.outroVideoX ?? 0;
const outroY = designMD.outroVideoY ?? 0;
const outroW = designMD.outroVideoW ?? 100;
const outroH = designMD.outroVideoH ?? 100;
const getOrigForElement = useCallback((element: DragElement) => {
switch (element) {
case 'logo': return { x: logoX, y: logoY };
case 'content': return { x: contentX, y: contentY };
case 'intro': return { x: introX, y: introY };
case 'outro': return { x: outroX, y: outroY };
default: return { x: 50, y: 50 };
}
}, [logoX, logoY, contentX, contentY, introX, introY, outroX, outroY]);
const handlePointerDown = useCallback((e: React.PointerEvent, element: DragElement) => {
if (!onDesignChange) return;
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDragElement(element);
const orig = getOrigForElement(element);
setDragStart({
x: e.clientX,
y: e.clientY,
origX: orig.x,
origY: orig.y,
});
}, [onDesignChange, getOrigForElement]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!dragElement || !dragStart || !overlayRef.current || !onDesignChange) return;
const rect = overlayRef.current.getBoundingClientRect();
const deltaXPct = ((e.clientX - dragStart.x) / rect.width) * 100;
const deltaYPct = ((e.clientY - dragStart.y) / rect.height) * 100;
// No clamping — allow elements to extend beyond canvas boundaries
const newX = Math.round(dragStart.origX + deltaXPct);
const newY = Math.round(dragStart.origY + deltaYPct);
if (dragElement === 'logo') {
onDesignChange('logoX', newX);
onDesignChange('logoY', newY);
} else if (dragElement === 'content') {
onDesignChange('contentX', newX);
onDesignChange('contentY', newY);
} else if (dragElement === 'intro') {
onDesignChange('introVideoX', newX);
onDesignChange('introVideoY', newY);
} else if (dragElement === 'outro') {
onDesignChange('outroVideoX', newX);
onDesignChange('outroVideoY', newY);
} else if (dragElement?.startsWith('intro-resize-')) {
const corner = dragElement.replace('intro-resize-', '');
if (corner === 'br') {
onDesignChange('introVideoW', Math.max(10, Math.round(introW + deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH + deltaYPct)));
} else if (corner === 'bl') {
onDesignChange('introVideoX', Math.round(introX + deltaXPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW - deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH + deltaYPct)));
} else if (corner === 'tr') {
onDesignChange('introVideoY', Math.round(introY + deltaYPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW + deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH - deltaYPct)));
} else if (corner === 'tl') {
onDesignChange('introVideoX', Math.round(introX + deltaXPct));
onDesignChange('introVideoY', Math.round(introY + deltaYPct));
onDesignChange('introVideoW', Math.max(10, Math.round(introW - deltaXPct)));
onDesignChange('introVideoH', Math.max(10, Math.round(introH - deltaYPct)));
}
setDragStart({ ...dragStart, x: e.clientX, y: e.clientY });
} else if (dragElement?.startsWith('outro-resize-')) {
const corner = dragElement.replace('outro-resize-', '');
if (corner === 'br') {
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW + deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH + deltaYPct)));
} else if (corner === 'bl') {
onDesignChange('outroVideoX', Math.round(outroX + deltaXPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW - deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH + deltaYPct)));
} else if (corner === 'tr') {
onDesignChange('outroVideoY', Math.round(outroY + deltaYPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW + deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH - deltaYPct)));
} else if (corner === 'tl') {
onDesignChange('outroVideoX', Math.round(outroX + deltaXPct));
onDesignChange('outroVideoY', Math.round(outroY + deltaYPct));
onDesignChange('outroVideoW', Math.max(10, Math.round(outroW - deltaXPct)));
onDesignChange('outroVideoH', Math.max(10, Math.round(outroH - deltaYPct)));
}
setDragStart({ ...dragStart, x: e.clientX, y: e.clientY });
}
}, [dragElement, dragStart, onDesignChange, introX, introY, introW, introH, outroX, outroY, outroW, outroH]);
const handlePointerUp = useCallback(() => {
setDragElement(null);
setDragStart(null);
}, []);
// Seek player to the focused segment when it changes
useEffect(() => {
if (!playerRef.current || !focusSegment) return;
const player = playerRef.current;
try {
player.pause();
let targetFrame = 0;
if (focusSegment === 'intro') targetFrame = introStart;
else if (focusSegment === 'content') targetFrame = contentStart;
else if (focusSegment === 'outro') targetFrame = outroStart;
player.seekTo(targetFrame);
} catch {
// Player may not be ready yet
}
}, [focusSegment, introStart, contentStart, outroStart]);
// Expose seek function to parent
useEffect(() => {
if (!playerRef.current || !onPlayerReady) return;
const player = playerRef.current;
onPlayerReady((frame: number) => {
try {
player.pause();
player.seekTo(frame);
} catch { /* noop */ }
});
}, [onPlayerReady]);
// Subscribe to frame updates
useEffect(() => {
if (!playerRef.current || !onFrameUpdate) return;
const player = playerRef.current;
const handler = (e: { detail: { frame: number } }) => {
onFrameUpdate(e.detail.frame);
};
player.addEventListener('frameupdate', handler);
return () => player.removeEventListener('frameupdate', handler);
}, [onFrameUpdate]);
const inputProps = useMemo(() => ({
designMD,
company,
introDur,
outroDur,
contentDur,
hasIntro,
hasOutro,
}), [designMD, company, introDur, outroDur, contentDur, hasIntro, hasOutro]);
// Whether we're in editing mode (a segment is focused)
const isEditing = !!focusSegment && focusSegment !== 'audio';
return (
<div className="flex flex-col items-center h-full max-h-full">
<CanvasWorkspace
aspectRatio={dims.css}
isEditing={isEditing}
canvasClassName="rounded-2xl shadow-2xl border border-neutral-800 bg-neutral-900"
overlayRef={overlayRef}
overlayPointerEvents={!!dragElement}
onOverlayPointerMove={handlePointerMove}
onOverlayPointerUp={handlePointerUp}
overlay={onDesignChange ? (
<>
{/* Logo drag handle — only when content segment is selected */}
{focusSegment === 'content' && (
<div
className={`absolute cursor-grab active:cursor-grabbing transition-all ${
dragElement === 'logo' ? 'z-20 scale-110' : 'hover:ring-2 hover:ring-violet-400/40 hover:ring-offset-2 hover:ring-offset-transparent'
}`}
style={{
left: `${logoX}%`,
top: `${logoY}%`,
pointerEvents: 'auto',
padding: '8px',
borderRadius: '8px',
}}
onPointerDown={(e) => handlePointerDown(e, 'logo')}
title="Arrastra para mover el logo"
>
<div className="bg-violet-500/10 border border-violet-500/30 rounded-lg px-3 py-1.5 backdrop-blur-sm">
<span className="text-[9px] font-bold text-violet-300 uppercase tracking-wider">Logo</span>
</div>
</div>
)}
{/* Content block drag handle — only when content segment is selected */}
{focusSegment === 'content' && (
<div
className={`absolute cursor-grab active:cursor-grabbing transition-all -translate-x-1/2 ${
dragElement === 'content' ? 'z-20 scale-110' : 'hover:ring-2 hover:ring-amber-400/40 hover:ring-offset-2 hover:ring-offset-transparent'
}`}
style={{
left: `${contentX}%`,
top: `${contentY}%`,
pointerEvents: 'auto',
padding: '8px',
borderRadius: '8px',
}}
onPointerDown={(e) => handlePointerDown(e, 'content')}
title="Arrastra para mover el bloque de texto"
>
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-1.5 backdrop-blur-sm">
<span className="text-[9px] font-bold text-amber-300 uppercase tracking-wider">Texto</span>
</div>
</div>
)}
{/* Intro video box — only when intro is selected */}
{hasIntro && focusSegment === 'intro' && (
<VideoBoxHandle
label="Intro"
color="emerald"
x={introX}
y={introY}
w={introW}
h={introH}
isDragging={dragElement === 'intro' || !!dragElement?.startsWith('intro-resize')}
onMoveDown={(e) => handlePointerDown(e, 'intro')}
onResizeDown={(e, corner) => handlePointerDown(e, `intro-resize-${corner}` as DragElement)}
/>
)}
{/* Outro video box — only when outro is selected */}
{hasOutro && focusSegment === 'outro' && (
<VideoBoxHandle
label="Outro"
color="rose"
x={outroX}
y={outroY}
w={outroW}
h={outroH}
isDragging={dragElement === 'outro' || !!dragElement?.startsWith('outro-resize')}
onMoveDown={(e) => handlePointerDown(e, 'outro')}
onResizeDown={(e, corner) => handlePointerDown(e, `outro-resize-${corner}` as DragElement)}
/>
)}
</>
) : undefined}
>
<Player
ref={playerRef}
component={SampleComposition}
inputProps={inputProps}
durationInFrames={Math.max(totalDur, 60)}
compositionWidth={dims.width}
compositionHeight={dims.height}
fps={30}
controls
loop={!focusSegment || focusSegment === 'audio'}
autoPlay={!focusSegment || focusSegment === 'audio'}
style={{
width: '100%',
height: '100%',
}}
/>
</CanvasWorkspace>
{/* Info bar */}
<div className="flex items-center gap-3 mt-3 shrink-0">
<span className="text-[10px] font-mono text-neutral-500">
{(totalDur / 30).toFixed(1)}s · {aspectRatio} · {dims.width}×{dims.height} · 30fps
</span>
<div className="flex gap-1.5">
{hasIntro && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 border border-violet-500/20">
INTRO
</span>
)}
<span className="text-[9px] px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-400 border border-neutral-700">
CONTENIDO
</span>
{hasOutro && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 border border-violet-500/20">
OUTRO
</span>
)}
</div>
{onDesignChange && (
<span className="text-[9px] text-violet-400/50 ml-1">
Drag handles para posicionar
</span>
)}
</div>
</div>
);
};
// ═══ Sample Remotion Composition ═══
interface SampleProps {
designMD: DesignMD;
company: CompanyProfile;
introDur: number;
outroDur: number;
contentDur: number;
hasIntro: boolean;
hasOutro: boolean;
}
const SampleComposition: React.FC<SampleProps> = ({
designMD,
company,
introDur,
outroDur,
contentDur,
hasIntro,
hasOutro,
}) => {
const contentStart = hasIntro ? introDur : 0;
const outroStart = contentStart + contentDur;
return (
<AbsoluteFill style={{ backgroundColor: designMD.secondaryColor }}>
{/* Brand Frame — always visible */}
<AbsoluteFill
style={{
border: `${designMD.frameThickness}px solid ${designMD.primaryColor}`,
boxSizing: 'border-box',
zIndex: 10,
pointerEvents: 'none',
}}
/>
{/* ── INTRO SEQUENCE ── */}
{hasIntro && (
<Sequence from={0} durationInFrames={introDur} name="Intro">
<IntroSection designMD={designMD} company={company} />
</Sequence>
)}
{/* ── CONTENT SEQUENCE ── */}
<Sequence from={contentStart} durationInFrames={contentDur} name="Content">
<ContentSection designMD={designMD} company={company} />
</Sequence>
{/* ── OUTRO SEQUENCE ── */}
{hasOutro && (
<Sequence from={outroStart} durationInFrames={outroDur} name="Outro">
<OutroSection designMD={designMD} company={company} />
</Sequence>
)}
</AbsoluteFill>
);
};
// ═══ INTRO ═══
const IntroSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (designMD.introVideoUrl) {
const vx = designMD.introVideoX ?? 0;
const vy = designMD.introVideoY ?? 0;
const vw = designMD.introVideoW ?? 100;
const vh = designMD.introVideoH ?? 100;
return (
<AbsoluteFill>
<div style={{
position: 'absolute',
left: `${vx}%`, top: `${vy}%`,
width: `${vw}%`, height: `${vh}%`,
overflow: 'hidden',
borderRadius: vw < 100 || vh < 100 ? 8 : 0,
}}>
<Video
src={designMD.introVideoUrl}
style={{
width: '100%',
height: '100%',
objectFit: (designMD.introVideoFit || 'cover') as React.CSSProperties['objectFit'],
}}
volume={0}
/>
</div>
{/* Logo overlay on intro video */}
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${designMD.logoX ?? 5}%`,
top: `${designMD.logoY ?? 5}%`,
opacity: interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' }),
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
</AbsoluteFill>
);
}
// Fallback placeholder intro
const scale = spring({ frame, fps, config: { damping: 12, stiffness: 80 } });
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: designMD.primaryColor }}>
<div style={{ transform: `scale(${scale})`, textAlign: 'center' }}>
{designMD.logoUrl && (
<img src={designMD.logoUrl} alt="" style={{ width: 240, margin: '0 auto 24px', objectFit: 'contain' }} />
)}
<h1 style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.textColor,
fontSize: 72,
fontWeight: 'bold',
}}>
{company.name || 'INTRO'}
</h1>
</div>
</AbsoluteFill>
);
};
// ═══ CONTENT ═══
const ContentSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const transitionIn = designMD.defaultTransitionIn || 'fade';
const transitionOut = designMD.defaultTransitionOut || 'none';
const entryStyle = getTransitionStyle(transitionIn, frame, fps, 'in');
const exitFrame = durationInFrames - frame;
const exitStyle = transitionOut !== 'none' ? getTransitionStyle(transitionOut, exitFrame, fps, 'out') : {};
const combinedTextStyle = frame < 25 ? entryStyle : exitFrame < 25 ? exitStyle : {};
// Use freeform positions if set, otherwise fall back to preset
const logoX = designMD.logoX ?? 5;
const logoY = designMD.logoY ?? 5;
const contentX = designMD.contentX ?? 50;
const contentY = designMD.contentY ?? 75;
return (
<AbsoluteFill
style={{
padding: `${designMD.frameThickness + 40}px`,
}}
>
{/* Logo — freeform positioned */}
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${logoX}%`,
top: `${logoY}%`,
...combinedTextStyle,
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
{/* Text block — freeform positioned */}
<div
style={{
position: 'absolute',
left: `${contentX}%`,
top: `${contentY}%`,
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(8px)',
padding: '48px 36px',
borderRadius: 24,
textAlign: 'center',
maxWidth: '80%',
...combinedTextStyle,
}}
>
<h1 style={{
fontFamily: designMD.titleFont || designMD.baseFont,
color: designMD.titleColor || designMD.textColor,
fontSize: designMD.titleSize || 64,
fontWeight: 'bold',
lineHeight: 1.1,
margin: 0,
}}>
{company.name || 'Tu Marca'}
</h1>
{company.tagline && (
<p style={{
fontFamily: designMD.subtitleFont || designMD.baseFont,
color: designMD.subtitleColor || designMD.textColor,
fontSize: designMD.subtitleSize || 32,
marginTop: 16,
opacity: 0.9,
lineHeight: 1.3,
}}>
{company.tagline}
</p>
)}
{company.socialLinks?.instagram && (
<p style={{
fontFamily: designMD.paragraphFont || designMD.baseFont,
color: designMD.primaryColor,
fontSize: 28,
marginTop: 24,
opacity: interpolate(frame, [30, 45], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
}}>
{company.socialLinks.instagram}
</p>
)}
</div>
</AbsoluteFill>
);
};
// ═══ OUTRO ═══
const OutroSection: React.FC<{ designMD: DesignMD; company: CompanyProfile }> = ({ designMD, company }) => {
const frame = useCurrentFrame();
if (designMD.outroVideoUrl) {
const vx = designMD.outroVideoX ?? 0;
const vy = designMD.outroVideoY ?? 0;
const vw = designMD.outroVideoW ?? 100;
const vh = designMD.outroVideoH ?? 100;
return (
<AbsoluteFill>
<div style={{
position: 'absolute',
left: `${vx}%`, top: `${vy}%`,
width: `${vw}%`, height: `${vh}%`,
overflow: 'hidden',
borderRadius: vw < 100 || vh < 100 ? 8 : 0,
}}>
<Video
src={designMD.outroVideoUrl}
style={{
width: '100%',
height: '100%',
objectFit: (designMD.outroVideoFit || 'cover') as React.CSSProperties['objectFit'],
}}
volume={0}
/>
</div>
{designMD.logoUrl && (
<div style={{
position: 'absolute',
left: `${designMD.logoX ?? 5}%`,
top: `${designMD.logoY ?? 5}%`,
opacity: interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' }),
}}>
<img src={designMD.logoUrl} alt="" style={{ width: 160, objectFit: 'contain' }} />
</div>
)}
</AbsoluteFill>
);
}
// Fallback placeholder outro
const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' });
return (
<AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: designMD.primaryColor, opacity }}>
<div style={{ textAlign: 'center' }}>
{designMD.logoUrl && (
<img src={designMD.logoUrl} alt="" style={{ width: 180, margin: '0 auto 20px', objectFit: 'contain' }} />
)}
<p style={{
fontFamily: designMD.baseFont,
color: designMD.textColor,
fontSize: 36,
opacity: 0.8,
}}>
{company.socialLinks?.website || company.socialLinks?.instagram || company.name}
</p>
</div>
</AbsoluteFill>
);
};
// ═══ HELPERS ═══
function getTransitionStyle(
type: string,
frame: number,
fps: number,
direction: 'in' | 'out'
): React.CSSProperties {
const progress = Math.min(frame / 18, 1);
switch (type) {
case 'fade':
return { opacity: progress };
case 'slideUp':
return { opacity: progress, transform: `translateY(${(1 - progress) * 80}px)` };
case 'slideRight':
return { opacity: progress, transform: `translateX(${(progress - 1) * 100}px)` };
case 'bounce': {
const s = spring({ frame, fps, config: { damping: 8, stiffness: 120 } });
return { transform: `scale(${s})` };
}
case 'scale':
return { opacity: progress, transform: `scale(${0.4 + progress * 0.6})` };
case 'typewriter':
return { opacity: Math.round(progress * 4) / 4 };
default:
return {};
}
}
// ═══ VideoBoxHandle — resizable rectangle with corner handles ═══
const CORNER_CURSORS: Record<string, string> = {
tl: 'nwse-resize', tr: 'nesw-resize',
bl: 'nesw-resize', br: 'nwse-resize',
};
interface VideoBoxHandleProps {
label: string;
color: 'emerald' | 'rose';
x: number; y: number; w: number; h: number;
isDragging: boolean;
onMoveDown: (e: React.PointerEvent) => void;
onResizeDown: (e: React.PointerEvent, corner: string) => void;
}
const COLOR_MAP = {
emerald: { border: '#10b981', bg: 'rgba(16,185,129,0.08)', text: '#6ee7b7', label: 'rgba(16,185,129,0.15)' },
rose: { border: '#f43f5e', bg: 'rgba(244,63,94,0.08)', text: '#fda4af', label: 'rgba(244,63,94,0.15)' },
};
const VideoBoxHandle: React.FC<VideoBoxHandleProps> = ({ label, color, x, y, w, h, isDragging, onMoveDown, onResizeDown }) => {
const c = COLOR_MAP[color];
return (
<div
className="absolute"
style={{
left: `${x}%`, top: `${y}%`,
width: `${w}%`, height: `${h}%`,
pointerEvents: 'auto',
zIndex: isDragging ? 30 : 10,
}}
>
{/* Border + background */}
<div
className="absolute inset-0 cursor-grab active:cursor-grabbing"
style={{
border: `2px ${isDragging ? 'solid' : 'dashed'} ${c.border}`,
borderRadius: '8px',
background: c.bg,
}}
onPointerDown={onMoveDown}
title={`Arrastra para mover ${label}`}
>
{/* Label */}
<div
className="absolute top-2 left-2 px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider backdrop-blur-sm"
style={{ background: c.label, color: c.text, border: `1px solid ${c.border}40` }}
>
{label}
</div>
{/* Size info */}
<div
className="absolute bottom-1 right-2 text-[8px] font-mono opacity-60"
style={{ color: c.text }}
>
{w}% × {h}%
</div>
</div>
{/* Corner resize handles */}
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => (
<div
key={corner}
className="absolute w-3 h-3 rounded-full border-2 bg-neutral-950"
style={{
borderColor: c.border,
cursor: CORNER_CURSORS[corner],
...(corner.includes('t') ? { top: -6 } : { bottom: -6 }),
...(corner.includes('l') ? { left: -6 } : { right: -6 }),
pointerEvents: 'auto',
zIndex: 40,
}}
onPointerDown={(e) => onResizeDown(e, corner)}
title={`Redimensionar ${label}`}
/>
))}
</div>
);
};
@@ -0,0 +1,180 @@
import React from 'react';
import { Film, Volume2, Monitor, Square, Smartphone } from 'lucide-react';
import { DesignMD } from '../../../types';
interface PreviewTimelineProps {
designMD: DesignMD;
aspectRatio?: '16:9' | '1:1' | '9:16';
}
const RATIO_INFO: Record<string, { icon: React.ReactNode; res: string; label: string }> = {
'16:9': { icon: <Monitor size={12} />, res: '1920×1080', label: 'Landscape' },
'1:1': { icon: <Square size={12} />, res: '1080×1080', label: 'Cuadrado' },
'9:16': { icon: <Smartphone size={12} />, res: '1080×1920', label: 'Vertical' },
};
/**
* Visual timeline mockup showing the video structure:
* intro transition content transition outro + audio status.
*/
export const PreviewTimeline: React.FC<PreviewTimelineProps> = ({ designMD, aspectRatio = '9:16' }) => {
const hasIntro = !!designMD.introVideoUrl;
const hasOutro = !!designMD.outroVideoUrl;
const hasAudio = !!designMD.brandAudioUrl;
const introDur = designMD.introDurationFrames || 60;
const outroDur = designMD.outroDurationFrames || 60;
const totalDur = (hasIntro ? introDur : 0) + (hasOutro ? outroDur : 0) || 1;
return (
<div className="w-full max-w-lg space-y-4">
{/* Timeline Blocks */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-[10px] font-mono uppercase tracking-widest text-neutral-500">Estructura del Video</h4>
<span className="text-[10px] font-mono text-neutral-600 flex items-center gap-1.5 bg-neutral-800/50 px-2 py-1 rounded-md">
{RATIO_INFO[aspectRatio]?.icon}
{aspectRatio} · {RATIO_INFO[aspectRatio]?.res}
</span>
</div>
{/* Timeline visual */}
<div className="flex items-center gap-1.5">
{/* Intro */}
{hasIntro && (
<TimelineBlock
label="INTRO"
icon={<Film size={14} />}
duration={introDur}
color={designMD.primaryColor}
widthPercent={(introDur / totalDur) * 100}
/>
)}
{/* Outro */}
{hasOutro && (
<TimelineBlock
label="OUTRO"
icon={<Film size={14} />}
duration={outroDur}
color={designMD.primaryColor}
widthPercent={(outroDur / totalDur) * 100}
/>
)}
</div>
{/* Duration */}
<div className="flex justify-between text-[10px] font-mono text-neutral-500">
<span>0:00</span>
<span>{(totalDur / 30).toFixed(1)}s · {RATIO_INFO[aspectRatio]?.label} · 30fps</span>
</div>
</div>
{/* Audio Status */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl p-6 space-y-3">
<h4 className="text-[10px] font-mono uppercase tracking-widest text-neutral-500">Audio de Marca</h4>
{hasAudio ? (
<div className="space-y-3">
{/* Audio waveform mockup */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-violet-500/20 border border-violet-500/30 flex items-center justify-center">
<Volume2 size={18} className="text-violet-400" />
</div>
<div className="flex-1">
<div className="flex items-end gap-[2px] h-6">
{Array.from({ length: 32 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-full bg-violet-500/60"
style={{
height: `${Math.max(2, Math.sin(i * 0.4) * 16 + Math.random() * 8 + 4)}px`,
opacity: getWaveformOpacity(i, 32, designMD),
}}
/>
))}
</div>
<p className="text-[10px] text-neutral-500 mt-1 font-mono truncate">
{designMD.brandAudioUrl?.split('/').pop() || 'audio.mp3'}
</p>
</div>
<span className="text-xs font-mono text-violet-300 bg-neutral-800 px-2 py-1 rounded">
{Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}%
</span>
</div>
{/* Fade indicators */}
<div className="flex gap-4">
{designMD.autoFadeInAudio && (
<span className="text-[10px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-1 rounded-md">
Fade-In {((designMD.audioFadeInDuration || 15) / 30).toFixed(1)}s
</span>
)}
{designMD.autoFadeOutAudio && (
<span className="text-[10px] text-amber-400 bg-amber-500/10 border border-amber-500/20 px-2 py-1 rounded-md">
Fade-Out {((designMD.audioFadeOutDuration || 15) / 30).toFixed(1)}s
</span>
)}
{!designMD.autoFadeInAudio && !designMD.autoFadeOutAudio && (
<span className="text-[10px] text-neutral-500">Sin fade automático</span>
)}
</div>
</div>
) : (
<div className="text-center py-4">
<Volume2 size={24} className="mx-auto text-neutral-700 mb-2" />
<p className="text-xs text-neutral-500">Sin audio de marca configurado</p>
</div>
)}
</div>
</div>
);
};
// ─── Sub-components ───
const TimelineBlock: React.FC<{
label: string;
icon: React.ReactNode;
duration: number;
color: string;
widthPercent: number;
isMain?: boolean;
}> = ({ label, icon, duration, color, widthPercent, isMain }) => (
<div
className="rounded-lg p-3 flex flex-col items-center justify-center text-center transition-all"
style={{
backgroundColor: `${color}${isMain ? '30' : '50'}`,
border: `1px solid ${color}60`,
flex: `${widthPercent} 0 0`,
minWidth: '70px',
}}
>
<span style={{ color }} className="mb-1">{icon}</span>
<span className="text-[9px] font-bold tracking-wider text-white opacity-80">{label}</span>
<span className="text-[9px] font-mono text-neutral-400 mt-0.5">{(duration / 30).toFixed(1)}s</span>
</div>
);
function getWaveformOpacity(i: number, total: number, designMD: DesignMD): number {
let opacity = 1;
const fadeInFrames = designMD.audioFadeInDuration || 15;
const fadeOutFrames = designMD.audioFadeOutDuration || 15;
const fadeInBars = Math.ceil((fadeInFrames / 300) * total);
const fadeOutBars = Math.ceil((fadeOutFrames / 300) * total);
if (designMD.autoFadeInAudio && i < fadeInBars) {
opacity = i / fadeInBars;
}
if (designMD.autoFadeOutAudio && i > total - fadeOutBars) {
opacity = (total - i) / fadeOutBars;
}
return Math.max(0.1, opacity);
}
@@ -0,0 +1,120 @@
import React from 'react';
import { DesignMD } from '../../../types';
interface PreviewTypographyProps {
designMD: DesignMD;
}
/**
* Isolated typography hierarchy preview showing all text levels
* with their real fonts, sizes, and colors.
*/
export const PreviewTypography: React.FC<PreviewTypographyProps> = ({ designMD }) => {
const titleFont = designMD.titleFont || designMD.baseFont;
const subtitleFont = designMD.subtitleFont || designMD.baseFont;
const paragraphFont = designMD.paragraphFont || designMD.baseFont;
const baseFont = designMD.baseFont;
const titleSize = designMD.titleSize || 64;
const subtitleSize = designMD.subtitleSize || 32;
const paragraphSize = designMD.paragraphSize || 16;
const titleColor = designMD.titleColor || designMD.textColor;
const subtitleColor = designMD.subtitleColor || designMD.textColor;
const paragraphColor = designMD.paragraphColor || designMD.textColor;
return (
<div
className="w-[420px] rounded-2xl overflow-hidden shadow-2xl"
style={{
backgroundColor: designMD.secondaryColor,
border: `${designMD.frameThickness}px solid ${designMD.primaryColor}`,
}}
>
<div className="p-8 space-y-6">
{/* Heading 1 */}
<div className="space-y-1">
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40" style={{ color: designMD.textColor }}>
Título {titleFont.split(',')[0].replace(/"/g, '')} · {titleSize}px
</span>
<h1
style={{
fontFamily: titleFont,
fontSize: `${Math.min(titleSize, 56)}px`,
color: titleColor,
lineHeight: 1.1,
}}
className="font-bold tracking-tight"
>
Título Principal
</h1>
<div className="h-px mt-2" style={{ backgroundColor: `${designMD.primaryColor}30` }} />
</div>
{/* Heading 2 */}
<div className="space-y-1">
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40" style={{ color: designMD.textColor }}>
Subtítulo {subtitleFont.split(',')[0].replace(/"/g, '')} · {subtitleSize}px
</span>
<h2
style={{
fontFamily: subtitleFont,
fontSize: `${Math.min(subtitleSize, 32)}px`,
color: subtitleColor,
lineHeight: 1.2,
}}
className="font-semibold"
>
Subtítulo de Sección
</h2>
<div className="h-px mt-2" style={{ backgroundColor: `${designMD.primaryColor}20` }} />
</div>
{/* Paragraph */}
<div className="space-y-1">
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40" style={{ color: designMD.textColor }}>
Párrafo {paragraphFont.split(',')[0].replace(/"/g, '')} · {paragraphSize}px
</span>
<p
style={{
fontFamily: paragraphFont,
fontSize: `${Math.min(paragraphSize, 18)}px`,
color: paragraphColor,
lineHeight: 1.6,
}}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
{/* Glyph Preview */}
<div
className="rounded-xl p-5 text-center space-y-2"
style={{ backgroundColor: `${designMD.primaryColor}10` }}
>
<span className="text-[9px] font-mono uppercase tracking-widest opacity-40 block" style={{ color: designMD.textColor }}>
Glifos {baseFont.split(',')[0].replace(/"/g, '')}
</span>
<p
className="text-2xl font-bold tracking-wider"
style={{ fontFamily: baseFont, color: titleColor }}
>
ABCDEFGHIJKLM
</p>
<p
className="text-2xl tracking-wider"
style={{ fontFamily: baseFont, color: subtitleColor }}
>
abcdefghijklm
</p>
<p
className="text-xl font-mono tracking-[0.3em]"
style={{ fontFamily: baseFont, color: paragraphColor, opacity: 0.7 }}
>
0123456789
</p>
</div>
</div>
</div>
);
};
@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Subtitles, Loader2, X } from 'lucide-react';
import { CAPTION_PRESETS, CaptionStyle, DEFAULT_CAPTION_STYLE } from '../../utils/captionGenerator';
interface CaptionStylePickerProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (style: CaptionStyle) => void;
isLoading: boolean;
}
/**
* CaptionStylePicker Modal for choosing caption style before generating auto-captions.
*/
export const CaptionStylePicker: React.FC<CaptionStylePickerProps> = ({
isOpen,
onClose,
onGenerate,
isLoading,
}) => {
const [selectedPreset, setSelectedPreset] = useState(0);
if (!isOpen) return null;
const currentStyle = CAPTION_PRESETS[selectedPreset]?.style ?? DEFAULT_CAPTION_STYLE;
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-neutral-900 border border-neutral-700 rounded-2xl w-[420px] shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Subtitles size={18} className="text-amber-400" />
<h3 className="text-sm font-bold text-white">Auto-Captions</h3>
</div>
<button
onClick={onClose}
title="Cerrar"
className="p-1 rounded hover:bg-neutral-800 text-neutral-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Style Presets */}
<div className="p-5 space-y-4">
<label className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Estilo de Subtítulos</label>
<div className="grid grid-cols-2 gap-2">
{CAPTION_PRESETS.map((preset, idx) => (
<button
key={preset.name}
onClick={() => setSelectedPreset(idx)}
title={`Estilo ${preset.name}`}
className={`p-3 rounded-xl border text-left transition-all ${
selectedPreset === idx
? 'border-violet-500/50 bg-violet-500/10 ring-1 ring-violet-500/20'
: 'border-neutral-800 bg-neutral-950/50 hover:border-neutral-700'
}`}
>
<span className="text-xs font-medium text-white">{preset.name}</span>
{/* Preview */}
<div
className="mt-2 px-2 py-1 rounded text-center text-[10px] leading-snug"
style={{
color: preset.style.color,
background: preset.style.backgroundColor || 'transparent',
fontSize: '11px',
fontWeight: 700,
}}
>
Hola, esto es un ejemplo
</div>
<div className="mt-1.5 text-[9px] text-neutral-500">
{preset.style.fontSize}px · {preset.style.position} · {preset.style.maxWordsPerGroup} palabras
</div>
</button>
))}
</div>
{/* Info */}
<div className="bg-neutral-950/50 rounded-lg p-3 border border-neutral-800/50">
<p className="text-[10px] text-neutral-400 leading-relaxed">
Se transcribirá el audio y se generarán subtítulos sincronizados palabra por palabra.
Los subtítulos se crearán como elementos de texto en una nueva capa.
</p>
</div>
</div>
{/* Actions */}
<div className="px-5 pb-5 flex gap-2">
<button
onClick={onClose}
title="Cancelar"
className="flex-1 py-2.5 rounded-xl border border-neutral-800 text-neutral-400 text-xs font-medium hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={() => onGenerate(currentStyle)}
disabled={isLoading}
title="Generar subtítulos automáticos"
className="flex-1 py-2.5 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 text-white text-xs font-bold hover:from-amber-400 hover:to-orange-400 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 size={14} className="animate-spin" />
Transcribiendo...
</>
) : (
<>
<Subtitles size={14} />
Generar Captions
</>
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,61 @@
import React from 'react';
import { Sequence, AbsoluteFill, Img, Video } from 'remotion';
import { TimelineElement, TimelineLayer, MediaFilter } from '../../types';
const getFilterStyle = (filter?: MediaFilter): React.CSSProperties => {
switch (filter) {
case 'grayscale':
return { filter: 'grayscale(100%)' };
case 'sepia':
return { filter: 'sepia(100%)' };
case 'contrast':
return { filter: 'contrast(150%)' };
default:
return {};
}
};
interface BackgroundLayerProps {
timelineElements: TimelineElement[];
layers: TimelineLayer[];
}
export const BackgroundLayer: React.FC<BackgroundLayerProps> = ({ timelineElements, layers }) => {
const backgroundElements = timelineElements.filter(
el => layers?.find(l => l.id === el.layerId)?.type === 'background'
);
return (
<>
{backgroundElements.map((el) => {
const filterStyle = getFilterStyle(el.filter || 'none');
return (
<Sequence key={el.id} from={el.startFrame} durationInFrames={el.endFrame - el.startFrame}>
<AbsoluteFill style={filterStyle}>
{el.type === 'color' && (
<div style={{
width: '100%',
height: '100%',
...(el.content.includes('gradient')
? { background: el.content }
: { backgroundColor: el.content }),
}}>
{el.backgroundPattern && (
<div style={{
position: 'absolute',
inset: 0,
backgroundImage: el.backgroundPattern,
backgroundSize: '10px 10px',
}} />
)}
</div>
)}
{el.type === 'image' && <Img src={el.content} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
{el.type === 'video' && <Video src={el.content} style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={(e) => console.warn('Video failed to load', e)} />}
</AbsoluteFill>
</Sequence>
);
})}
</>
);
};
@@ -0,0 +1,57 @@
import React from 'react';
import { AbsoluteFill } from 'remotion';
import { DesignMD } from '../../types';
interface BrandOverlayProps {
designMD: DesignMD;
textOverlay: string;
brandVisibility?: { logo: boolean; frame: boolean };
}
export const BrandOverlay: React.FC<BrandOverlayProps> = ({ designMD, textOverlay, brandVisibility }) => {
const showLogo = brandVisibility?.logo ?? true;
const showFrame = brandVisibility?.frame ?? true;
return (
<AbsoluteFill
style={{
border: showFrame ? `${designMD.frameThickness}px solid ${designMD.primaryColor}` : 'none',
boxSizing: 'border-box',
padding: '40px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
pointerEvents: 'none',
}}
>
{/* Cabecera: Logo de la Marca */}
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
{showLogo && designMD.logoUrl && (
<img
src={designMD.logoUrl}
alt="Brand Logo"
style={{ width: '120px', objectFit: 'contain' }}
/>
)}
</div>
{/* Pie: Texto sin manipulación de la imagen original */}
<div
style={{
fontFamily: designMD.baseFont,
color: designMD.textColor,
fontSize: '48px',
fontWeight: 'bold',
textAlign: 'center',
textShadow: '2px 2px 4px rgba(0,0,0,0.8)',
backgroundColor: 'rgba(0,0,0,0.4)',
padding: '24px',
borderRadius: '16px',
backdropFilter: 'blur(4px)',
}}
>
{textOverlay}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,56 @@
import React from 'react';
interface CanvasGridOverlayProps {
visible: boolean;
cols?: number;
rows?: number;
}
/**
* CanvasGridOverlay Renders a semi-transparent grid overlay on the canvas.
* Uses CSS repeating-linear-gradient for performance (no DOM nodes per line).
*/
export const CanvasGridOverlay: React.FC<CanvasGridOverlayProps> = ({
visible,
cols = 6,
rows = 6,
}) => {
if (!visible) return null;
const colWidth = 100 / cols;
const rowHeight = 100 / rows;
return (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 40,
pointerEvents: 'none',
backgroundImage: `
repeating-linear-gradient(90deg, rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px, transparent 1px, transparent ${colWidth}%),
repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px, transparent 1px, transparent ${rowHeight}%)
`,
backgroundSize: `${colWidth}% ${rowHeight}%`,
}}
>
{/* Center crosshair */}
<div style={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: '1px',
backgroundColor: 'rgba(168,85,247,0.15)',
}} />
<div style={{
position: 'absolute',
top: '50%',
left: 0,
right: 0,
height: '1px',
backgroundColor: 'rgba(168,85,247,0.15)',
}} />
</div>
);
};
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
interface CanvasRulersProps {
width: number;
height: number;
zoom: number;
}
/**
* CanvasRulers Horizontal and vertical pixel rulers on canvas edges.
* Shows tick marks every 100px with labels.
*/
export const CanvasRulers: React.FC<CanvasRulersProps> = ({ width, height, zoom }) => {
const step = 100; // pixels between major ticks
const hTicks = Math.ceil(width / step);
const vTicks = Math.ceil(height / step);
return (
<>
{/* Horizontal Ruler (top) */}
<div
style={{
position: 'absolute',
top: -16,
left: 0,
width: '100%',
height: 14,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 30,
}}
>
{Array.from({ length: hTicks + 1 }, (_, i) => (
<div
key={i}
style={{
position: 'absolute',
left: `${(i * step / width) * 100}%`,
bottom: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<span
style={{
fontSize: 7,
fontFamily: 'monospace',
color: 'rgba(161, 161, 170, 0.4)',
userSelect: 'none',
lineHeight: 1,
}}
>
{i * step}
</span>
<div
style={{
width: 1,
height: 4,
backgroundColor: 'rgba(161, 161, 170, 0.25)',
}}
/>
</div>
))}
</div>
{/* Vertical Ruler (left) */}
<div
style={{
position: 'absolute',
top: 0,
left: -22,
width: 18,
height: '100%',
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 30,
}}
>
{Array.from({ length: vTicks + 1 }, (_, i) => (
<div
key={i}
style={{
position: 'absolute',
top: `${(i * step / height) * 100}%`,
right: 0,
display: 'flex',
alignItems: 'center',
gap: 2,
}}
>
<span
style={{
fontSize: 7,
fontFamily: 'monospace',
color: 'rgba(161, 161, 170, 0.4)',
userSelect: 'none',
lineHeight: 1,
writingMode: 'vertical-lr',
transform: 'rotate(180deg)',
}}
>
{i * step}
</span>
<div
style={{
width: 4,
height: 1,
backgroundColor: 'rgba(161, 161, 170, 0.25)',
}}
/>
</div>
))}
</div>
</>
);
};
@@ -0,0 +1,146 @@
import React, { useRef, useEffect, useMemo } from 'react';
import {
applyChromaKey,
hexToRgb,
mapToleranceToDistance,
mapSoftnessToDistance,
} from '../../utils/chromaKeyUtils';
import { ChromaKeyShader } from '../../utils/ChromaKeyShader';
interface ChromaKeyImageProps {
src: string;
chromaKeyColor: string;
chromaKeyTolerance: number;
chromaKeySoftness: number;
style?: React.CSSProperties;
draggable?: boolean;
}
// Cache WebGL support check
let webglSupported: boolean | null = null;
function isWebGLSupported(): boolean {
if (webglSupported === null) {
webglSupported = ChromaKeyShader.isSupported();
}
return webglSupported;
}
/**
* Renders an image with chroma key background removal.
*
* Attempts WebGL2 shader processing for GPU acceleration.
* Falls back to Canvas 2D pixel manipulation if WebGL is unavailable.
* The result is cached re-processes only when parameters change.
*/
export const ChromaKeyImage: React.FC<ChromaKeyImageProps> = ({
src,
chromaKeyColor,
chromaKeyTolerance,
chromaKeySoftness,
style = {},
draggable = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const shaderRef = useRef<ChromaKeyShader | null>(null);
const useWebGL = useRef(isWebGLSupported());
const keyColor = useMemo(() => hexToRgb(chromaKeyColor), [chromaKeyColor]);
const canvas2dParams = useMemo(() => ({
keyColor,
tolerance: mapToleranceToDistance(chromaKeyTolerance),
softness: mapSoftnessToDistance(chromaKeySoftness),
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
const webglParams = useMemo(() => ({
keyColor,
tolerance: chromaKeyTolerance / 100 * 0.8, // Normalize to 0-0.8 range for shader
softness: chromaKeySoftness / 100 * 0.4, // Normalize to 0-0.4 range
spillSuppress: 0.5,
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
// Cleanup shader on unmount
useEffect(() => {
return () => {
shaderRef.current?.dispose();
shaderRef.current = null;
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !src) return;
const processWithImage = (img: HTMLImageElement) => {
if (useWebGL.current) {
try {
// Initialize shader lazily
if (!shaderRef.current) {
shaderRef.current = new ChromaKeyShader(canvas);
}
shaderRef.current.render(img, webglParams);
} catch (e) {
console.warn('ChromaKeyImage: WebGL failed, falling back to Canvas 2D', e);
useWebGL.current = false;
shaderRef.current?.dispose();
shaderRef.current = null;
processCanvas2D(img, canvas, canvas2dParams);
}
} else {
processCanvas2D(img, canvas, canvas2dParams);
}
};
// If image already loaded, process immediately
if (imgRef.current && imgRef.current.src === src && imgRef.current.complete) {
processWithImage(imgRef.current);
return;
}
// Load image
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
imgRef.current = img;
processWithImage(img);
};
img.onerror = () => {
console.warn('ChromaKeyImage: failed to load image', src);
};
img.src = src;
return () => {
img.onload = null;
img.onerror = null;
};
}, [src, canvas2dParams, webglParams]);
return (
<canvas
ref={canvasRef}
style={{
...style,
imageRendering: 'auto',
}}
draggable={draggable}
/>
);
};
function processCanvas2D(
img: HTMLImageElement,
canvas: HTMLCanvasElement,
params: { keyColor: [number, number, number]; tolerance: number; softness: number }
): void {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
applyChromaKey(imageData, params);
ctx.putImageData(imageData, 0, 0);
}
@@ -0,0 +1,191 @@
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import { useCurrentFrame, useVideoConfig } from 'remotion';
import {
applyChromaKey,
hexToRgb,
mapToleranceToDistance,
mapSoftnessToDistance,
} from '../../utils/chromaKeyUtils';
import { ChromaKeyShader } from '../../utils/ChromaKeyShader';
interface ChromaKeyVideoProps {
src: string;
chromaKeyColor: string;
chromaKeyTolerance: number;
chromaKeySoftness: number;
style?: React.CSSProperties;
volume?: number | ((frame: number) => number);
}
// Cache WebGL support check
let webglSupported: boolean | null = null;
function isWebGLSupported(): boolean {
if (webglSupported === null) {
webglSupported = ChromaKeyShader.isSupported();
}
return webglSupported;
}
/**
* Renders a video with chroma key background removal.
*
* Uses a hidden <video> element synced to Remotion's current frame.
* Attempts WebGL2 shader processing for GPU-accelerated performance.
* Falls back to Canvas 2D pixel manipulation if WebGL is unavailable.
*
* Canvas 2D fallback processes at 50% resolution for performance.
*/
export const ChromaKeyVideo: React.FC<ChromaKeyVideoProps> = ({
src,
chromaKeyColor,
chromaKeyTolerance,
chromaKeySoftness,
style = {},
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const canvasRef = useRef<HTMLCanvasElement>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const shaderRef = useRef<ChromaKeyShader | null>(null);
const rafRef = useRef<number>(0);
const useWebGL = useRef(isWebGLSupported());
const keyColor = useMemo(() => hexToRgb(chromaKeyColor), [chromaKeyColor]);
const canvas2dParams = useMemo(() => ({
keyColor,
tolerance: mapToleranceToDistance(chromaKeyTolerance),
softness: mapSoftnessToDistance(chromaKeySoftness),
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
const webglParams = useMemo(() => ({
keyColor,
tolerance: chromaKeyTolerance / 100 * 0.8,
softness: chromaKeySoftness / 100 * 0.4,
spillSuppress: 0.5,
}), [keyColor, chromaKeyTolerance, chromaKeySoftness]);
// Cleanup shader on unmount
useEffect(() => {
return () => {
shaderRef.current?.dispose();
shaderRef.current = null;
cancelAnimationFrame(rafRef.current);
};
}, []);
// Process current video frame
const processFrame = useCallback(() => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas || video.readyState < 2) return;
if (useWebGL.current) {
try {
if (!shaderRef.current) {
shaderRef.current = new ChromaKeyShader(canvas);
}
shaderRef.current.render(video, webglParams);
} catch (e) {
console.warn('ChromaKeyVideo: WebGL failed, falling back to Canvas 2D', e);
useWebGL.current = false;
shaderRef.current?.dispose();
shaderRef.current = null;
processCanvas2D(video, canvas, canvas2dParams);
}
} else {
processCanvas2D(video, canvas, canvas2dParams);
}
}, [canvas2dParams, webglParams]);
// Sync video time to Remotion frame
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const targetTime = frame / fps;
// Only seek if significantly out of sync
if (Math.abs(video.currentTime - targetTime) > 0.05) {
video.currentTime = targetTime;
}
// Process after seek
const onSeeked = () => processFrame();
video.addEventListener('seeked', onSeeked, { once: true });
// Also process immediately if video is ready
if (video.readyState >= 2) {
rafRef.current = requestAnimationFrame(processFrame);
}
return () => {
video.removeEventListener('seeked', onSeeked);
cancelAnimationFrame(rafRef.current);
};
}, [frame, fps, processFrame]);
// Re-process when chroma key params change
useEffect(() => {
if (videoRef.current && videoRef.current.readyState >= 2) {
processFrame();
}
}, [canvas2dParams, webglParams, processFrame]);
return (
<div style={{ position: 'relative', ...style }}>
{/* Hidden video element for frame source */}
<video
ref={videoRef}
src={src}
style={{
position: 'absolute',
width: 0,
height: 0,
opacity: 0,
pointerEvents: 'none',
}}
muted
playsInline
preload="auto"
crossOrigin="anonymous"
/>
{/* Visible canvas with processed frames */}
<canvas
ref={canvasRef}
style={{
width: '100%',
height: '100%',
objectFit: (style as any).objectFit || 'contain',
pointerEvents: 'none',
userSelect: 'none',
}}
/>
</div>
);
};
function processCanvas2D(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
params: { keyColor: [number, number, number]; tolerance: number; softness: number }
): void {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
// Process at 50% resolution for performance
const scale = 0.5;
const w = Math.round(video.videoWidth * scale);
const h = Math.round(video.videoHeight * scale);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
ctx.clearRect(0, 0, w, h);
ctx.drawImage(video, 0, 0, w, h);
const imageData = ctx.getImageData(0, 0, w, h);
applyChromaKey(imageData, params);
ctx.putImageData(imageData, 0, 0);
}
@@ -0,0 +1,628 @@
import React, { RefObject, useEffect } from 'react';
import { Sequence, AbsoluteFill, Img, Video, Audio, interpolate } from 'remotion';
import { TimelineElement, TimelineLayer, DesignMD } from '../../types';
import { calculateElementTransitions } from './useTransitions';
import { resolveKeyframes } from './keyframeEngine';
import { ChromaKeyImage } from './ChromaKeyImage';
import { ChromaKeyVideo } from './ChromaKeyVideo';
import type { CanvasActionMode } from './ElementActionToolbar';
import { loadGoogleFont } from '../../utils/googleFontsApi';
interface CompositionElementProps {
element: TimelineElement;
layer: TimelineLayer | undefined;
designMD: DesignMD;
frame: number;
selectedElementId: string | null;
activeLayerId: string | null;
activeAction: CanvasActionMode;
isImageMode?: boolean;
tempPositions: Record<string, { x: number; y: number; scale?: number; rotation?: number }>;
dragStateId: string | null;
containerRef: RefObject<HTMLDivElement>;
onElementClick?: (id: string) => void;
onElementDoubleClick?: (id: string) => void;
onElementContextMenu?: (id: string, e: React.MouseEvent) => void;
onDragStart: (id: string, startX: number, startY: number, initialElX: number, initialElY: number) => void;
onTransformStart: (id: string, type: 'scale' | 'rotate', startX: number, startY: number, initialScale: number, initialRot: number, centerX: number, centerY: number) => void;
onElementDuplicate?: (id: string) => void;
onElementDelete?: (id: string) => void;
onElementLock?: (id: string) => void;
}
export const CompositionElement: React.FC<CompositionElementProps> = ({
element: el,
layer,
designMD,
frame,
selectedElementId,
activeLayerId,
activeAction,
isImageMode = false,
tempPositions,
dragStateId,
containerRef,
onElementClick,
onElementDoubleClick,
onElementContextMenu,
onDragStart,
onTransformStart,
onElementDuplicate,
onElementDelete,
onElementLock,
}) => {
// ─── Dynamic font loading for text elements ───
const fontFamily = el.type === 'text' ? (el.fontFamily ?? designMD.baseFont) : null;
useEffect(() => {
if (fontFamily) loadGoogleFont(fontFamily);
}, [fontFamily]);
// In image mode: all non-locked elements are interactive (Photoshop model)
// In video mode: only elements on the active layer are interactive
const isInteractive = isImageMode
? !el.isLocked
: (!!activeLayerId && el.layerId === activeLayerId) && !el.isLocked;
// Skip hidden elements (after all hooks to satisfy Rules of Hooks)
if (el.isHidden) return null;
const isSelected = selectedElementId === el.id;
const layerOpacity = layer?.opacity ?? 1;
const baseOpacity = ((el.opacity ?? 100) / 100) * layerOpacity;
const currentScale = tempPositions[el.id]?.scale ?? el.scale ?? 1;
const currentRot = tempPositions[el.id]?.rotation ?? el.rotation ?? 0;
const tempX = tempPositions[el.id]?.x;
const tempY = tempPositions[el.id]?.y;
const { opacity, transformStr, displayContent } = calculateElementTransitions(
el, frame, baseOpacity, currentScale, currentRot, tempX, tempY
);
// Resolve position — multi-keyframes take priority over legacy animEnd*
let currentX = tempX ?? el.x;
let currentY = tempY ?? el.y;
if (el.keyframes && el.keyframes.length >= 2 && !tempPositions[el.id]) {
// Multi-keyframe: resolve x/y from keyframe engine
const resolved = resolveKeyframes(el.keyframes, frame, {
x: el.x, y: el.y,
scale: currentScale, opacity: baseOpacity, rotation: currentRot,
});
currentX = resolved.x;
currentY = resolved.y;
} else if (!el.keyframes) {
// Legacy 2-point keyframes
if (el.animEndX !== undefined) {
currentX = interpolate(frame, [el.startFrame, el.endFrame], [tempX ?? el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
if (el.animEndY !== undefined) {
currentY = interpolate(frame, [el.startFrame, el.endFrame], [tempY ?? el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
}
const isFullscreenBrand = el.isBrandElement && (el.brandDisplayMode ?? 'fullscreen') === 'fullscreen' && el.type === 'video';
const resolvedBlendMode = (() => {
// When chroma key is active, transparency is handled by the canvas — no CSS blend needed
if (el.chromaKeyEnabled) return 'normal';
if (!el.isBrandElement) return el.blendMode || 'normal';
if (el.content === designMD.introVideoUrl) return designMD.introBlendMode || el.blendMode || 'normal';
if (el.content === designMD.outroVideoUrl) return designMD.outroBlendMode || el.blendMode || 'normal';
return el.blendMode || 'normal';
})();
// Chroma key defaults
const ckColor = el.chromaKeyColor || '#ffffff';
const ckTolerance = el.chromaKeyTolerance ?? 30;
const ckSoftness = el.chromaKeySoftness ?? 10;
const filterStr = `brightness(${el.brightness ?? 100}%) contrast(${el.contrast ?? 100}%) saturate(${el.saturation ?? 100}%)${el.hueRotate ? ` hue-rotate(${el.hueRotate}deg)` : ''}${el.sepia ? ` sepia(${el.sepia}%)` : ''}${el.blurAmount ? ` blur(${el.blurAmount}px)` : ''}`;
// Contain background: wrap media in a colored container when objectFit='contain' and color is set
const hasContainBg = (el.objectFit === 'contain' || !el.objectFit) && !!el.containBgColor;
const containBgStyle: React.CSSProperties | undefined = hasContainBg ? {
width: '100%',
height: el.height ? '100%' : 'auto',
backgroundColor: el.containBgColor!,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
} : undefined;
// ── Transform helpers ──
const startScaleDrag = (e: React.PointerEvent) => {
e.stopPropagation();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
onTransformStart(
el.id, 'scale', e.clientX, e.clientY,
currentScale, currentRot,
rect.left + (currentX / 100) * rect.width,
rect.top + (currentY / 100) * rect.height
);
};
const startRotateDrag = (e: React.PointerEvent) => {
e.stopPropagation();
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
onTransformStart(
el.id, 'rotate', e.clientX, e.clientY,
currentScale, currentRot,
rect.left + (currentX / 100) * rect.width,
rect.top + (currentY / 100) * rect.height
);
};
const startDrag = (e: React.PointerEvent) => {
if (!isInteractive) return;
e.stopPropagation();
if (e.button === 2) return;
if (onElementClick) onElementClick(el.id);
// In move mode: drag moves. In scale/rotate: start respective transform.
if (activeAction === 'move') {
onDragStart(el.id, e.clientX, e.clientY, currentX, currentY);
} else if (activeAction === 'scale') {
startScaleDrag(e);
} else if (activeAction === 'rotate') {
startRotateDrag(e);
}
};
// ── Selection outline color ──
const outlineColor = el.isLocked ? '#d97706' : '#8b5cf6';
return (
<Sequence from={el.startFrame} durationInFrames={Math.max(1, el.endFrame - el.startFrame)}>
{el.type === 'audio' ? ((() => {
const layerVol = (layer?.volume ?? 100) / 100;
const elVol = el.volume ?? 1;
const isMuted = layer?.isMuted === true;
// Build volume callback for Remotion <Audio>
const volumeCallback = (f: number) => {
if (isMuted) return 0;
let vol = layerVol * elVol;
// Fade in
const fadeIn = el.fadeInFrames ?? 0;
if (fadeIn > 0 && f < fadeIn) {
vol *= interpolate(f, [0, fadeIn], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
}
// Fade out
const fadeOut = el.fadeOutFrames ?? 0;
const clipDuration = el.endFrame - el.startFrame;
if (fadeOut > 0 && f > clipDuration - fadeOut) {
vol *= interpolate(f, [clipDuration - fadeOut, clipDuration], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
}
// Volume keyframes
const vkfs = el.volumeKeyframes;
if (vkfs && vkfs.length > 0) {
const sorted = [...vkfs].sort((a, b) => a.frame - b.frame);
let before = sorted[0];
let after = sorted[sorted.length - 1];
for (let i = 0; i < sorted.length - 1; i++) {
if (f >= sorted[i].frame && f <= sorted[i + 1].frame) {
before = sorted[i];
after = sorted[i + 1];
break;
}
}
if (f <= before.frame) {
vol *= before.volume;
} else if (f >= after.frame) {
vol *= after.volume;
} else {
const kfVol = interpolate(f, [before.frame, after.frame], [before.volume, after.volume], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
vol *= kfVol;
}
}
return Math.max(0, Math.min(1, vol));
};
return <Audio src={el.content} volume={volumeCallback} />;
})()) : isFullscreenBrand ? (
/* ═══ Fullscreen Brand Video ═══ */
<AbsoluteFill
style={{
cursor: isInteractive ? 'pointer' : 'default',
pointerEvents: isInteractive ? 'auto' : 'none',
mixBlendMode: resolvedBlendMode !== 'normal' ? resolvedBlendMode as React.CSSProperties['mixBlendMode'] : undefined,
}}
onClick={(e) => {
e.stopPropagation();
if (isInteractive && onElementClick) onElementClick(el.id);
}}
>
{/* Positioned container — matches branding preview (PreviewRemotion) */}
<div style={{
position: 'absolute',
left: `${el.x ?? 0}%`,
top: `${el.y ?? 0}%`,
width: `${el.w ?? 100}%`,
height: `${el.h ?? 100}%`,
overflow: 'hidden',
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
}}>
{el.chromaKeyEnabled ? (
<ChromaKeyVideo
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
style={{
width: '100%',
height: '100%',
objectFit: (el.objectFit || (() => {
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
return 'cover';
})()) as React.CSSProperties['objectFit'],
opacity: opacity,
filter: filterStr,
}}
/>
) : (
<Video
src={el.content}
volume={el.volume ?? 1}
style={{
width: '100%',
height: '100%',
objectFit: (el.objectFit || (() => {
if (el.content === designMD.introVideoUrl) return designMD.introVideoFit || 'cover';
if (el.content === designMD.outroVideoUrl) return designMD.outroVideoFit || 'cover';
return 'cover';
})()) as React.CSSProperties['objectFit'],
opacity: opacity,
pointerEvents: 'none',
filter: filterStr,
}}
/>
)}
</div>
{isSelected && (
<div style={{
position: 'absolute',
left: `${el.x ?? 0}%`,
top: `${el.y ?? 0}%`,
width: `${el.w ?? 100}%`,
height: `${el.h ?? 100}%`,
border: '3px solid #d97706',
pointerEvents: 'none',
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
}} />
)}
</AbsoluteFill>
) : (
/* ═══ Normal Positioned Element ═══ */
<AbsoluteFill style={{ pointerEvents: 'none' }}>
<div
style={{
position: 'absolute',
left: `${currentX}%`,
top: `${currentY}%`,
width: el.type === 'text' ? (el.width ? `${el.width}%` : undefined) : `${el.width ?? 25}%`,
height: el.height ? `${el.height}%` : undefined,
transform: `${transformStr}${el.flipH ? ' scaleX(-1)' : ''}${el.flipV ? ' scaleY(-1)' : ''}`,
opacity: opacity,
cursor: isInteractive
? (activeAction === 'move'
? (dragStateId === el.id ? 'grabbing' : 'grab')
: activeAction === 'scale' ? 'nwse-resize'
: activeAction === 'rotate' ? 'alias'
: 'grab')
: (el.isLocked ? 'not-allowed' : 'default'),
outline: isSelected ? `${Math.max(1, 3 / currentScale)}px dashed ${outlineColor}` : 'none',
outlineOffset: `${6 / currentScale}px`,
pointerEvents: isInteractive || isSelected ? 'auto' : 'none',
mixBlendMode: resolvedBlendMode !== 'normal' ? resolvedBlendMode as React.CSSProperties['mixBlendMode'] : undefined,
border: el.borderWidth ? `${el.borderWidth}px ${el.borderStyle ?? 'solid'} ${el.borderColor ?? '#ffffff'}` : undefined,
borderRadius: el.borderRadius ? `${el.borderRadius}px` : undefined,
overflow: (el.height || el.borderRadius) ? 'hidden' : undefined,
boxShadow: el.boxShadowBlur || el.boxShadowX || el.boxShadowY
? `${el.boxShadowX ?? 0}px ${el.boxShadowY ?? 4}px ${el.boxShadowBlur ?? 10}px ${el.boxShadowColor ?? 'rgba(0,0,0,0.5)'}`
: undefined,
}}
onClick={(e) => { e.stopPropagation(); }}
onDoubleClick={(e) => {
e.stopPropagation();
if (isInteractive && onElementDoubleClick) onElementDoubleClick(el.id);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
if (isInteractive && onElementContextMenu) onElementContextMenu(el.id, e as unknown as React.MouseEvent);
}}
onPointerDown={startDrag}
>
{/* ── Content ── */}
{el.type === 'text' ? (
<div
style={{
fontFamily: el.fontFamily ?? designMD.baseFont,
color: el.color ?? designMD.textColor,
fontSize: el.fontSize ? `${el.fontSize}px` : '56px',
fontWeight: el.fontWeight ?? 'bold',
fontStyle: el.fontStyle ?? 'normal',
textDecoration: el.textDecoration && el.textDecoration !== 'none' ? el.textDecoration : undefined,
textShadow: `${el.shadowOffset ?? 3}px ${el.shadowOffset ?? 3}px ${el.shadowBlur ?? 6}px ${el.shadowColor ?? 'rgba(0,0,0,0.8)'}`,
textAlign: el.textAlign ?? 'center',
lineHeight: el.lineHeight ?? 1.2,
letterSpacing: el.letterSpacing ? `${el.letterSpacing}px` : undefined,
textTransform: el.textTransform ?? 'none',
WebkitTextStroke: el.textStrokeWidth
? `${el.textStrokeWidth}px ${el.textStrokeColor ?? '#000000'}`
: undefined,
// Gradient text (overrides solid color)
...(el.textGradient ? {
background: el.textGradient,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
} : el.textBackground ? {
// Text background (pill/highlight)
background: el.textBackground,
padding: `${el.textBackgroundPadding ?? 8}px ${(el.textBackgroundPadding ?? 8) * 2}px`,
borderRadius: `${el.textBackgroundRadius ?? 4}px`,
display: 'inline-block',
} : {}),
whiteSpace: 'pre-wrap',
userSelect: 'none',
pointerEvents: 'none',
}}
>
{displayContent}
</div>
) : el.type === 'video' ? (
(() => {
const videoContent = el.chromaKeyEnabled ? (
<ChromaKeyVideo
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
playbackRate={el.playbackRate}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
) : (
<Video
src={el.content}
volume={el.volume ?? 1}
playbackRate={el.playbackRate ?? 1}
startFrom={el.trimStartSec ? Math.round(el.trimStartSec * 30) : undefined}
endAt={el.trimEndSec ? Math.round(el.trimEndSec * 30) : undefined}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
/>
);
return hasContainBg ? <div style={containBgStyle}>{videoContent}</div> : videoContent;
})()
) : el.type === 'shape' ? (
/* ── Shape Element (SVG) ── */
(() => {
const sw = el.width ?? 25;
const fill = el.shapeFill ?? '#ffffff';
const stroke = el.shapeStroke ?? 'none';
const strokeW = el.shapeStrokeWidth ?? 0;
const cr = el.shapeCornerRadius ?? 0;
const svgStyle: React.CSSProperties = {
width: '100%',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
};
switch (el.shapeType) {
case 'circle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<circle cx="50" cy="50" r={48 - strokeW / 2} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
case 'triangle':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,2 98,98 2,98" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'line':
return (
<svg viewBox="0 0 100 10" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="5" x2="100" y2="5" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
</svg>
);
case 'arrow':
return (
<svg viewBox="0 0 100 40" style={svgStyle} preserveAspectRatio="none">
<line x1="0" y1="20" x2="80" y2="20" stroke={stroke || fill} strokeWidth={strokeW || 3} strokeLinecap="round" />
<polygon points="75,5 100,20 75,35" fill={stroke || fill} />
</svg>
);
case 'star':
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<polygon points="50,0 61,35 98,35 68,57 79,91 50,70 21,91 32,57 2,35 39,35" fill={fill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
</svg>
);
case 'rectangle':
default:
return (
<svg viewBox="0 0 100 100" style={svgStyle}>
<rect x={strokeW / 2} y={strokeW / 2} width={100 - strokeW} height={100 - strokeW} rx={cr} ry={cr} fill={fill} stroke={stroke} strokeWidth={strokeW} />
</svg>
);
}
})()
) : el.isPlaceholder ? (
/* ── Placeholder for empty media fields ── */
<div
style={{
width: '100%',
height: el.height ? `${el.height}%` : '100%',
aspectRatio: el.height ? undefined : '16/9',
border: '2px dashed rgba(255,255,255,0.2)',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
background: 'rgba(255,255,255,0.03)',
pointerEvents: 'none',
userSelect: 'none',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
{el.placeholderLabel && (
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)', fontFamily: 'system-ui, sans-serif', textAlign: 'center' }}>
{el.placeholderLabel}
</span>
)}
</div>
) : (
(() => {
const imgContent = el.chromaKeyEnabled ? (
<ChromaKeyImage
src={el.content}
chromaKeyColor={ckColor}
chromaKeyTolerance={ckTolerance}
chromaKeySoftness={ckSoftness}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
) : (
<Img
src={el.content}
style={{
width: '100%',
height: el.height ? '100%' : 'auto',
objectFit: el.objectFit ?? 'contain',
objectPosition: el.objectPosition ?? 'center center',
pointerEvents: 'none',
userSelect: 'none',
filter: filterStr,
}}
draggable={false}
/>
);
return hasContainBg ? <div style={containBgStyle}>{imgContent}</div> : imgContent;
})()
)}
{/* ═══ Scale Handles — only in Scale mode ═══ */}
{isSelected && activeAction === 'scale' && (
<>
{(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((corner) => {
const isTop = corner.includes('top');
const isLeft = corner.includes('left');
const cursorH = (isTop === isLeft) ? 'nwse-resize' : 'nesw-resize';
return (
<div
key={corner}
style={{
position: 'absolute',
[isTop ? 'top' : 'bottom']: -7 / currentScale,
[isLeft ? 'left' : 'right']: -7 / currentScale,
width: 14 / currentScale,
height: 14 / currentScale,
background: '#fff',
border: `${Math.max(1, 2 / currentScale)}px solid #8b5cf6`,
borderRadius: 3 / currentScale,
cursor: cursorH,
pointerEvents: 'auto',
zIndex: 10,
boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
}}
onPointerDown={startScaleDrag}
title="Redimensionar"
/>
);
})}
</>
)}
{/* ═══ Rotate Handle — only in Rotate mode ═══ */}
{isSelected && activeAction === 'rotate' && (
<>
{/* Connector line from element bottom to rotate handle */}
<div
style={{
position: 'absolute', bottom: -24 / currentScale, left: '50%',
transform: `translateX(-50%) scaleY(${1 / currentScale})`,
transformOrigin: 'top center',
width: 1, height: 20,
background: '#8b5cf6',
pointerEvents: 'none',
zIndex: 9,
}}
/>
{/* Rotate handle circle */}
<div
style={{
position: 'absolute', bottom: -38 / currentScale, left: '50%',
transform: `translateX(-50%) scale(${1 / currentScale})`,
transformOrigin: 'top center',
width: 22, height: 22,
background: '#fff', border: '2px solid #8b5cf6',
borderRadius: '50%', cursor: 'grab',
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 51,
boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
}}
onPointerDown={startRotateDrag}
title="Arrastra para rotar"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="3"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.59-9.21l5.67-4.24"/></svg>
</div>
</>
)}
</div>
</AbsoluteFill>
)}
</Sequence>
);
};
@@ -0,0 +1,241 @@
import React from 'react';
export type CanvasActionMode = 'move' | 'scale' | 'rotate';
interface ElementActionToolbarProps {
activeAction: CanvasActionMode;
setActiveAction: (action: CanvasActionMode) => void;
isLocked: boolean;
isBrandElement: boolean;
onDuplicate: () => void;
onDelete: () => void;
onLock: () => void;
counterScale?: number;
// Keyframe props
hasKeyframes?: boolean;
hasKeyframeAtCurrentFrame?: boolean;
onToggleKeyframe?: () => void;
onPrevKeyframe?: () => void;
onNextKeyframe?: () => void;
}
/**
* Floating toolbar rendered above a selected canvas element.
* Controls the active interaction mode (move/scale/rotate) and provides
* quick actions (duplicate, lock, delete, keyframe toggle).
*/
export const ElementActionToolbar: React.FC<ElementActionToolbarProps> = ({
activeAction,
setActiveAction,
isLocked,
isBrandElement,
onDuplicate,
onDelete,
onLock,
counterScale = 1,
hasKeyframes = false,
hasKeyframeAtCurrentFrame = false,
onToggleKeyframe,
onPrevKeyframe,
onNextKeyframe,
}) => {
return (
<div
style={{
zIndex: 50,
pointerEvents: 'auto',
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 2,
background: 'rgba(23, 23, 23, 0.95)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(82, 82, 82, 0.4)',
borderRadius: 8,
padding: '3px 4px',
boxShadow: '0 4px 20px rgba(0,0,0,0.5), 0 0 0 1px rgba(139,92,246,0.15)',
}}
>
{/* ── Mode buttons ── */}
<ToolbarBtn
active={activeAction === 'move'}
onClick={() => setActiveAction('move')}
title="Mover (M)"
disabled={isLocked}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="5 9 2 12 5 15" /><polyline points="9 5 12 2 15 5" />
<polyline points="15 19 12 22 9 19" /><polyline points="19 9 22 12 19 15" />
<line x1="2" y1="12" x2="22" y2="12" /><line x1="12" y1="2" x2="12" y2="22" />
</svg>
</ToolbarBtn>
<ToolbarBtn
active={activeAction === 'scale'}
onClick={() => setActiveAction('scale')}
title="Redimensionar (S)"
disabled={isLocked}
>
<svg width="14" height="14" 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>
</ToolbarBtn>
<ToolbarBtn
active={activeAction === 'rotate'}
onClick={() => setActiveAction('rotate')}
title="Rotar (R)"
disabled={isLocked}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.5 2v6h-6" /><path d="M21.34 15.57a10 10 0 1 1-.59-9.21l5.67-4.24" />
</svg>
</ToolbarBtn>
{/* Separator */}
<div style={{ width: 1, height: 18, background: 'rgba(82,82,82,0.5)', margin: '0 2px' }} />
{/* ── Keyframe toggle (CapCut style) ── */}
{hasKeyframes && onPrevKeyframe && (
<ToolbarBtn onClick={onPrevKeyframe} title="Keyframe anterior (←)" disabled={isLocked}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</ToolbarBtn>
)}
{onToggleKeyframe && (
<ToolbarBtn
onClick={onToggleKeyframe}
title={hasKeyframeAtCurrentFrame ? 'Eliminar keyframe' : 'Agregar keyframe'}
active={hasKeyframeAtCurrentFrame}
disabled={isLocked}
>
{/* Diamond icon ◆ */}
<svg width="14" height="14" viewBox="0 0 24 24">
<path
d="M12 2 L22 12 L12 22 L2 12 Z"
fill={hasKeyframeAtCurrentFrame ? '#a78bfa' : 'none'}
stroke={hasKeyframeAtCurrentFrame ? '#a78bfa' : 'currentColor'}
strokeWidth="2"
/>
</svg>
</ToolbarBtn>
)}
{hasKeyframes && onNextKeyframe && (
<ToolbarBtn onClick={onNextKeyframe} title="Siguiente keyframe (→)" disabled={isLocked}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</ToolbarBtn>
)}
{(onToggleKeyframe || hasKeyframes) && (
<div style={{ width: 1, height: 18, background: 'rgba(82,82,82,0.5)', margin: '0 2px' }} />
)}
{/* ── Quick actions ── */}
<ToolbarBtn onClick={onDuplicate} title="Duplicar (D)" disabled={isLocked}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</ToolbarBtn>
<ToolbarBtn
onClick={onLock}
title={isLocked ? 'Desbloquear' : 'Bloquear'}
active={isLocked}
>
{isLocked ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 9.9-1" />
</svg>
)}
</ToolbarBtn>
{!isBrandElement && (
<ToolbarBtn onClick={onDelete} title="Eliminar (⌫)" danger>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</ToolbarBtn>
)}
</div>
</div>
);
};
// ─── Button sub-component ──────────────────────────────
interface ToolbarBtnProps {
children: React.ReactNode;
onClick?: () => void;
title: string;
active?: boolean;
disabled?: boolean;
danger?: boolean;
}
const ToolbarBtn: React.FC<ToolbarBtnProps> = ({ children, onClick, title, active, disabled, danger }) => {
const bg = active
? 'rgba(139, 92, 246, 0.3)'
: 'transparent';
const color = danger
? 'rgb(248, 113, 113)'
: active
? 'rgb(196, 167, 255)'
: 'rgb(163, 163, 163)';
const hoverBg = danger
? 'rgba(248, 113, 113, 0.15)'
: 'rgba(255, 255, 255, 0.08)';
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
onPointerDown={(e) => e.stopPropagation()}
title={title}
disabled={disabled}
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
border: active ? '1px solid rgba(139, 92, 246, 0.5)' : '1px solid transparent',
background: bg,
color: color,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.3 : 1,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!disabled && !active) {
e.currentTarget.style.background = hoverBg;
e.currentTarget.style.color = danger ? 'rgb(248, 113, 113)' : 'white';
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.background = bg;
e.currentTarget.style.color = color;
}
}}
>
{children}
</button>
);
};
@@ -0,0 +1,93 @@
import React from 'react';
interface SafeZoneOverlayProps {
visible: boolean;
}
/**
* Displays social media safe zone guides on the canvas.
* Shows title-safe (80%) and action-safe (90%) zones.
*/
export const SafeZoneOverlay: React.FC<SafeZoneOverlayProps> = ({ visible }) => {
if (!visible) return null;
return (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 45,
}}
>
{/* Title Safe (80%) */}
<div
style={{
position: 'absolute',
left: '10%',
top: '10%',
right: '10%',
bottom: '10%',
border: '1px dashed rgba(236, 72, 153, 0.4)',
borderRadius: 4,
}}
>
<span
style={{
position: 'absolute',
top: -14,
left: 4,
fontSize: 8,
color: 'rgba(236, 72, 153, 0.6)',
fontFamily: 'monospace',
userSelect: 'none',
}}
>
Title Safe 80%
</span>
</div>
{/* Action Safe (90%) */}
<div
style={{
position: 'absolute',
left: '5%',
top: '5%',
right: '5%',
bottom: '5%',
border: '1px dashed rgba(168, 85, 247, 0.3)',
borderRadius: 4,
}}
>
<span
style={{
position: 'absolute',
top: -14,
left: 4,
fontSize: 8,
color: 'rgba(168, 85, 247, 0.5)',
fontFamily: 'monospace',
userSelect: 'none',
}}
>
Action Safe 90%
</span>
</div>
{/* Center cross */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: 20,
height: 20,
transform: 'translate(-50%, -50%)',
}}
>
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1, backgroundColor: 'rgba(168, 85, 247, 0.2)' }} />
<div style={{ position: 'absolute', top: '50%', left: 0, right: 0, height: 1, backgroundColor: 'rgba(168, 85, 247, 0.2)' }} />
</div>
</div>
);
};
@@ -0,0 +1,18 @@
import React from 'react';
interface SmartGuidesProps {
guides: { x: number | null; y: number | null };
}
export const SmartGuides: React.FC<SmartGuidesProps> = ({ guides }) => {
return (
<>
{guides.x !== null && (
<div style={{ position: 'absolute', left: `${guides.x}%`, top: 0, bottom: 0, width: '2px', backgroundColor: '#ec4899', zIndex: 50, pointerEvents: 'none' }} />
)}
{guides.y !== null && (
<div style={{ position: 'absolute', top: `${guides.y}%`, left: 0, right: 0, height: '2px', backgroundColor: '#ec4899', zIndex: 50, pointerEvents: 'none' }} />
)}
</>
);
};
@@ -0,0 +1,158 @@
import { interpolate, Easing } from 'remotion';
import { AnimationKeyframe, EasingType } from '../../types';
interface KeyframeDefaults {
x: number;
y: number;
scale: number;
opacity: number;
rotation: number;
}
interface ResolvedValues {
x: number;
y: number;
scale: number;
opacity: number;
rotation: number;
}
/**
* Map EasingType to a Remotion Easing function.
*/
function getEasingFn(type: EasingType = 'linear'): (t: number) => number {
switch (type) {
case 'ease-in': return Easing.in(Easing.ease);
case 'ease-out': return Easing.out(Easing.ease);
case 'ease-in-out': return Easing.inOut(Easing.ease);
case 'bounce': return Easing.bounce;
case 'spring': return Easing.out(Easing.ease); // Approximation — spring is better via spring()
case 'linear':
default: return Easing.linear;
}
}
/**
* Build a filled property array from keyframes.
* If a keyframe doesn't define a property, it inherits the previous keyframe's value.
*/
function buildPropertyTrack(
sortedKfs: AnimationKeyframe[],
property: keyof Omit<AnimationKeyframe, 'frame' | 'easing'>,
defaultValue: number
): number[] {
let lastValue = defaultValue;
return sortedKfs.map(kf => {
const v = kf[property];
if (v !== undefined) {
lastValue = v;
return v;
}
return lastValue;
});
}
/**
* Resolve multi-keyframe interpolation for a given frame.
*
* @param keyframes - Array of keyframes (will be sorted by frame)
* @param frame - Current absolute frame number
* @param defaults - Default values for all properties (used before the first keyframe)
* @returns Interpolated property values at the given frame
*/
export function resolveKeyframes(
keyframes: AnimationKeyframe[],
frame: number,
defaults: KeyframeDefaults
): ResolvedValues {
if (keyframes.length === 0) return { ...defaults };
// Sort by frame
const sorted = [...keyframes].sort((a, b) => a.frame - b.frame);
// If only one keyframe, return its values (no interpolation)
if (sorted.length === 1) {
const kf = sorted[0];
return {
x: kf.x ?? defaults.x,
y: kf.y ?? defaults.y,
scale: kf.scale ?? defaults.scale,
opacity: kf.opacity ?? defaults.opacity,
rotation: kf.rotation ?? defaults.rotation,
};
}
// Build frame array (inputRange)
const frames = sorted.map(kf => kf.frame);
// Build easing array (one per segment = keyframes.length - 1)
const easings = sorted.slice(1).map(kf => getEasingFn(kf.easing));
// Build and interpolate each property
const interpolateProperty = (
property: keyof Omit<AnimationKeyframe, 'frame' | 'easing'>,
defaultValue: number
): number => {
const values = buildPropertyTrack(sorted, property, defaultValue);
return interpolate(frame, frames, values, {
easing: easings as ((t: number) => number)[],
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
};
return {
x: interpolateProperty('x', defaults.x),
y: interpolateProperty('y', defaults.y),
scale: interpolateProperty('scale', defaults.scale),
opacity: interpolateProperty('opacity', defaults.opacity),
rotation: interpolateProperty('rotation', defaults.rotation),
};
}
/**
* Find the keyframe at a specific frame (within ±tolerance).
*/
export function findKeyframeAtFrame(
keyframes: AnimationKeyframe[],
frame: number,
tolerance: number = 2
): { index: number; keyframe: AnimationKeyframe } | null {
for (let i = 0; i < keyframes.length; i++) {
if (Math.abs(keyframes[i].frame - frame) <= tolerance) {
return { index: i, keyframe: keyframes[i] };
}
}
return null;
}
/**
* Add or update a keyframe at a specific frame.
* If a keyframe exists within ±tolerance, update it. Otherwise, insert a new one.
*/
export function upsertKeyframe(
keyframes: AnimationKeyframe[],
frame: number,
values: Partial<Omit<AnimationKeyframe, 'frame'>>,
tolerance: number = 2
): AnimationKeyframe[] {
const existing = findKeyframeAtFrame(keyframes, frame, tolerance);
if (existing) {
// Update existing keyframe
return keyframes.map((kf, i) =>
i === existing.index ? { ...kf, ...values } : kf
);
}
// Insert new keyframe
return [...keyframes, { frame, ...values }].sort((a, b) => a.frame - b.frame);
}
/**
* Remove a keyframe at a specific index.
*/
export function removeKeyframe(
keyframes: AnimationKeyframe[],
index: number
): AnimationKeyframe[] {
return keyframes.filter((_, i) => i !== index);
}
+235
View File
@@ -0,0 +1,235 @@
import React, { useState, useEffect, useRef, RefObject } from 'react';
import { TimelineElement } from '../../types';
interface CanvasDragState {
id: string;
startX: number;
startY: number;
initialElX: number;
initialElY: number;
}
interface TransformDragState {
id: string;
type: 'scale' | 'rotate';
startX: number;
startY: number;
initialScale: number;
initialRot: number;
centerX: number;
centerY: number;
}
interface TempPosition {
x: number;
y: number;
scale?: number;
rotation?: number;
}
interface Guides {
x: number | null;
y: number | null;
}
interface UseCanvasDragReturn {
containerRef: RefObject<HTMLDivElement>;
dragState: CanvasDragState | null;
setDragState: React.Dispatch<React.SetStateAction<CanvasDragState | null>>;
transformDragState: TransformDragState | null;
setTransformDragState: React.Dispatch<React.SetStateAction<TransformDragState | null>>;
tempPositions: Record<string, TempPosition>;
guides: Guides;
}
export function useCanvasDrag(
timelineElements: TimelineElement[],
onElementPositionChange?: (id: string, x: number, y: number) => void,
onElementTransformChange?: (id: string, updates: Partial<TimelineElement>) => void
): UseCanvasDragReturn {
const containerRef = useRef<HTMLDivElement>(null);
const [dragState, setDragState] = useState<CanvasDragState | null>(null);
const [transformDragState, setTransformDragState] = useState<TransformDragState | null>(null);
const [guides, setGuides] = useState<Guides>({ x: null, y: null });
const [tempPositions, setTempPositions] = useState<Record<string, TempPosition>>({});
// Stable refs to avoid effect re-runs
const tempPositionsRef = useRef(tempPositions);
tempPositionsRef.current = tempPositions;
const elementsRef = useRef(timelineElements);
elementsRef.current = timelineElements;
const onPosChangeRef = useRef(onElementPositionChange);
onPosChangeRef.current = onElementPositionChange;
const onTransformChangeRef = useRef(onElementTransformChange);
onTransformChangeRef.current = onElementTransformChange;
useEffect(() => {
if (!dragState && !transformDragState) return;
let rafId: number | null = null;
const handlePointerMove = (e: PointerEvent) => {
if (rafId) return; // Throttle to 60fps via rAF
rafId = requestAnimationFrame(() => {
rafId = null;
handleDragUpdate(e);
});
};
const handleDragUpdate = (e: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (transformDragState) {
if (transformDragState.type === 'scale') {
// Distance-from-center approach: works correctly from ANY corner.
// Compare distance from element center at start vs now.
const startDist = Math.sqrt(
Math.pow(transformDragState.startX - transformDragState.centerX, 2) +
Math.pow(transformDragState.startY - transformDragState.centerY, 2)
);
const currentDist = Math.sqrt(
Math.pow(e.clientX - transformDragState.centerX, 2) +
Math.pow(e.clientY - transformDragState.centerY, 2)
);
// Ratio: if pointer moves farther from center → bigger, closer → smaller
const distRatio = startDist > 0 ? currentDist / startDist : 1;
const ratio = Math.max(0.05, transformDragState.initialScale * distRatio);
const el = elementsRef.current.find(e => e.id === transformDragState.id);
setTempPositions(prev => ({
...prev,
[transformDragState.id]: {
x: prev[transformDragState.id]?.x ?? el?.x ?? 50,
y: prev[transformDragState.id]?.y ?? el?.y ?? 50,
scale: ratio,
rotation: prev[transformDragState.id]?.rotation,
}
}));
} else if (transformDragState.type === 'rotate') {
const currentAngle = Math.atan2(e.clientY - transformDragState.centerY, e.clientX - transformDragState.centerX);
const initialAngle = Math.atan2(transformDragState.startY - transformDragState.centerY, transformDragState.startX - transformDragState.centerX);
const diff = (currentAngle - initialAngle) * (180 / Math.PI);
const newRot = transformDragState.initialRot + diff;
const el = elementsRef.current.find(e => e.id === transformDragState.id);
setTempPositions(prev => ({
...prev,
[transformDragState.id]: {
x: prev[transformDragState.id]?.x ?? el?.x ?? 50,
y: prev[transformDragState.id]?.y ?? el?.y ?? 50,
scale: prev[transformDragState.id]?.scale,
rotation: newRot,
}
}));
}
return;
}
if (dragState) {
// Convert pixel delta to percentage of container
const dxPct = (rect.width > 0) ? ((e.clientX - dragState.startX) / rect.width) * 100 : 0;
const dyPct = (rect.height > 0) ? ((e.clientY - dragState.startY) / rect.height) * 100 : 0;
let newX = dragState.initialElX + dxPct;
let newY = dragState.initialElY + dyPct;
// Allow elements to go slightly out of bounds for edge positioning
newX = Math.max(-20, Math.min(120, newX));
newY = Math.max(-20, Math.min(120, newY));
// Snapping logic (Smart Guides)
let snapX: number | null = null;
let snapY: number | null = null;
const snapThreshold = 1.5;
// Snap to center
if (Math.abs(newX - 50) < snapThreshold) { newX = 50; snapX = 50; }
if (Math.abs(newY - 50) < snapThreshold) { newY = 50; snapY = 50; }
// Snap to edges
if (Math.abs(newX) < snapThreshold) { newX = 0; snapX = 0; }
if (Math.abs(newX - 100) < snapThreshold) { newX = 100; snapX = 100; }
if (Math.abs(newY) < snapThreshold) { newY = 0; snapY = 0; }
if (Math.abs(newY - 100) < snapThreshold) { newY = 100; snapY = 100; }
// Snap to quarter grid (25%, 75%)
for (const q of [25, 75]) {
if (Math.abs(newX - q) < snapThreshold) { newX = q; snapX = q; }
if (Math.abs(newY - q) < snapThreshold) { newY = q; snapY = q; }
}
// Snap to other elements (center and edges)
elementsRef.current.forEach(el => {
if (el.id !== dragState.id) {
// Center snap
if (Math.abs(newX - el.x) < snapThreshold) { newX = el.x; snapX = el.x; }
if (Math.abs(newY - el.y) < snapThreshold) { newY = el.y; snapY = el.y; }
}
});
setGuides({ x: snapX, y: snapY });
setTempPositions(prev => ({
...prev,
[dragState.id]: {
...prev[dragState.id],
x: newX,
y: newY
}
}));
}
};
const handlePointerUp = () => {
const temps = tempPositionsRef.current;
if (transformDragState && onTransformChangeRef.current) {
const temp = temps[transformDragState.id];
if (temp) {
const updates: Partial<TimelineElement> = {};
if (temp.scale !== undefined) updates.scale = temp.scale;
if (temp.rotation !== undefined) updates.rotation = temp.rotation;
onTransformChangeRef.current(transformDragState.id, updates);
}
} else if (dragState && onPosChangeRef.current && temps[dragState.id]) {
onPosChangeRef.current(
dragState.id,
temps[dragState.id].x,
temps[dragState.id].y
);
}
const currentDragId = dragState?.id;
const currentTransformId = transformDragState?.id;
setDragState(null);
setTransformDragState(null);
setGuides({ x: null, y: null });
// Clean up temp positions after a short delay to avoid flicker
setTimeout(() => setTempPositions(prev => {
const next = { ...prev };
if (currentDragId) delete next[currentDragId];
if (currentTransformId) delete next[currentTransformId];
return next;
}), 30);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
return () => {
if (rafId) cancelAnimationFrame(rafId);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [dragState, transformDragState]);
return {
containerRef,
dragState,
setDragState,
transformDragState,
setTransformDragState,
tempPositions,
guides,
};
}
@@ -0,0 +1,216 @@
import { interpolate, spring } from 'remotion';
import { TimelineElement } from '../../types';
import { resolveKeyframes } from './keyframeEngine';
interface TransitionResult {
opacity: number;
transformStr: string;
displayContent: string;
}
/**
* Calculate full transition state (in, out, typewriter, keyframe animations)
* for a single timeline element at a given frame.
*/
export function calculateElementTransitions(
el: TimelineElement,
frame: number,
baseOpacity: number,
currentScale: number,
currentRot: number,
tempX?: number,
tempY?: number
): TransitionResult {
let opacity = baseOpacity;
let transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
let displayContent = el.content;
// --- IN TRANSITIONS ---
if (el.transitionIn) {
const { type, duration } = el.transitionIn;
if (type === 'fade') {
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'slideUp') {
const translateY = interpolate(frame, [el.startFrame, el.startFrame + duration], [50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideRight') {
const translateX = interpolate(frame, [el.startFrame, el.startFrame + duration], [-50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'bounce') {
const scaleAnim = spring({
frame: frame - el.startFrame,
fps: 30,
config: { damping: 10, stiffness: 100, mass: 1 },
});
transformStr = `translate(-50%, -50%) scale(${scaleAnim * currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'scale') {
const scaleAnim = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${scaleAnim * currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideDown') {
const translateY = interpolate(frame, [el.startFrame, el.startFrame + duration], [-50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideLeft') {
const translateX = interpolate(frame, [el.startFrame, el.startFrame + duration], [50, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'blur') {
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'spin') {
const spinDeg = interpolate(frame, [el.startFrame, el.startFrame + duration], [360, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [el.startFrame, el.startFrame + duration], [0, baseOpacity], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot + spinDeg}deg)`;
} else if (type === 'flip') {
const flipDeg = interpolate(frame, [el.startFrame, el.startFrame + duration], [90, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot}deg) rotateY(${flipDeg}deg)`;
}
}
// --- OUT TRANSITIONS ---
if (el.transitionOut) {
const { type, duration } = el.transitionOut;
const outStart = el.endFrame - duration;
if (type === 'fade') {
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'slideUp') {
const translateY = interpolate(frame, [outStart, el.endFrame], [0, 50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideRight') {
const translateX = interpolate(frame, [outStart, el.endFrame], [0, 50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'scale' || type === 'bounce') {
const scaleAnim = interpolate(frame, [outStart, el.endFrame], [1, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${scaleAnim * currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideDown') {
const translateY = interpolate(frame, [outStart, el.endFrame], [0, -50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, calc(-50% + ${translateY}px)) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'slideLeft') {
const translateX = interpolate(frame, [outStart, el.endFrame], [0, -50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(calc(-50% + ${translateX}px), -50%) scale(${currentScale}) rotate(${currentRot}deg)`;
} else if (type === 'blur') {
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
} else if (type === 'spin') {
const spinDeg = interpolate(frame, [outStart, el.endFrame], [0, 360], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
opacity = interpolate(frame, [outStart, el.endFrame], [baseOpacity, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot + spinDeg}deg)`;
} else if (type === 'flip') {
const flipDeg = interpolate(frame, [outStart, el.endFrame], [0, 90], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
});
transformStr = `translate(-50%, -50%) scale(${currentScale}) rotate(${currentRot}deg) rotateY(${flipDeg}deg)`;
}
}
// --- TYPEWRITER ---
if (el.type === 'text') {
if (el.transitionIn?.type === 'typewriter' && frame <= el.startFrame + el.transitionIn.duration) {
const lettersCount = el.content.length;
const visibleLetters = Math.floor(interpolate(frame, [el.startFrame, el.startFrame + el.transitionIn.duration], [0, lettersCount], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
}));
displayContent = el.content.substring(0, visibleLetters);
} else if (el.transitionOut?.type === 'typewriter' && frame >= el.endFrame - el.transitionOut.duration) {
const lettersCount = el.content.length;
const visibleLetters = Math.floor(interpolate(frame, [el.endFrame - el.transitionOut.duration, el.endFrame], [lettersCount, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp'
}));
displayContent = el.content.substring(0, visibleLetters);
}
}
// --- KEYFRAME ANIMATIONS ---
if (el.keyframes && el.keyframes.length >= 2) {
// ── Multi-keyframe engine ──
const resolved = resolveKeyframes(el.keyframes, frame, {
x: tempX ?? el.x,
y: tempY ?? el.y,
scale: currentScale,
opacity: opacity,
rotation: currentRot,
});
// Note: x/y are applied in CompositionElement via tempPositions, not in transformStr
// But scale, rotation, and opacity need to update transformStr
transformStr = `translate(-50%, -50%) scale(${resolved.scale}) rotate(${resolved.rotation}deg)`;
opacity = resolved.opacity;
// Return resolved x/y through the transform (CompositionElement reads these)
return { opacity, transformStr, displayContent };
}
// --- Legacy 2-point Keyframe Interpolations (backwards compatible) ---
if (el.animEndX !== undefined) {
interpolate(frame, [el.startFrame, el.endFrame], [tempX ?? el.x, el.animEndX], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
if (el.animEndY !== undefined) {
interpolate(frame, [el.startFrame, el.endFrame], [tempY ?? el.y, el.animEndY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
if (el.animEndScale !== undefined) {
currentScale = interpolate(frame, [el.startFrame, el.endFrame], [currentScale, el.animEndScale], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
transformStr = transformStr.replace(/scale\([\d.]+\)/, `scale(${currentScale})`);
}
if (el.animEndOpacity !== undefined) {
opacity = opacity * interpolate(frame, [el.startFrame, el.endFrame], [1, el.animEndOpacity / 100], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
}
return { opacity, transformStr, displayContent };
}
@@ -0,0 +1,235 @@
import React, { useState, useMemo, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
import { ContentPiece, ContentPillar } from '../../types';
import { ContentCard } from './ContentCard';
interface CalendarViewProps {
pieces: ContentPiece[];
pillars: ContentPillar[];
onPieceClick: (piece: ContentPiece) => void;
onCreatePiece: (date: string) => void;
onDropPiece: (pieceId: string, newDate: string) => void;
}
const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
const MONTHS_ES = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
/**
* Monthly calendar view inspired by Later/Planable.
* Shows content pieces in day cells with drag-and-drop rescheduling.
*/
export const CalendarView: React.FC<CalendarViewProps> = ({
pieces,
pillars,
onPieceClick,
onCreatePiece,
onDropPiece,
}) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [dragOverDate, setDragOverDate] = useState<string | null>(null);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Generate calendar grid (6 weeks × 7 days)
const calendarDays = useMemo(() => {
const firstDay = new Date(year, month, 1);
// Adjust so Monday = 0
const startDow = (firstDay.getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: { date: Date; isCurrentMonth: boolean }[] = [];
// Previous month fill
const prevMonthDays = new Date(year, month, 0).getDate();
for (let i = startDow - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthDays - i),
isCurrentMonth: false,
});
}
// Current month
for (let d = 1; d <= daysInMonth; d++) {
days.push({
date: new Date(year, month, d),
isCurrentMonth: true,
});
}
// Next month fill (to complete 6 rows)
const remaining = 42 - days.length;
for (let d = 1; d <= remaining; d++) {
days.push({
date: new Date(year, month + 1, d),
isCurrentMonth: false,
});
}
return days;
}, [year, month]);
// Group pieces by date
const piecesByDate = useMemo(() => {
const map: Record<string, ContentPiece[]> = {};
pieces.forEach(p => {
if (p.scheduledDate) {
const key = p.scheduledDate;
if (!map[key]) map[key] = [];
map[key].push(p);
}
});
return map;
}, [pieces]);
const toDateKey = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const isToday = (date: Date) => {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
};
const goToPrev = () => setCurrentDate(new Date(year, month - 1, 1));
const goToNext = () => setCurrentDate(new Date(year, month + 1, 1));
const goToToday = () => setCurrentDate(new Date());
const handleDragOver = useCallback((e: React.DragEvent, dateKey: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverDate(dateKey);
}, []);
const handleDrop = useCallback((e: React.DragEvent, dateKey: string) => {
e.preventDefault();
const pieceId = e.dataTransfer.getData('text/piece-id');
if (pieceId) {
onDropPiece(pieceId, dateKey);
}
setDragOverDate(null);
}, [onDropPiece]);
const handleDragStart = useCallback((e: React.DragEvent, piece: ContentPiece) => {
e.dataTransfer.setData('text/piece-id', piece.id);
e.dataTransfer.effectAllowed = 'move';
}, []);
return (
<div className="flex flex-col h-full">
{/* Calendar Header */}
<div className="flex items-center justify-between px-1 pb-4">
<div className="flex items-center gap-3">
<h3 className="text-lg font-bold text-white">
{MONTHS_ES[month]} {year}
</h3>
<button
onClick={goToToday}
className="px-2 py-1 text-[10px] font-semibold text-violet-400 bg-violet-600/10 border border-violet-500/20 rounded-lg hover:bg-violet-600/20 transition-all"
title="Ir a hoy"
>
Hoy
</button>
</div>
<div className="flex items-center gap-1">
<button
onClick={goToPrev}
className="p-1.5 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Mes anterior"
>
<ChevronLeft size={18} />
</button>
<button
onClick={goToNext}
className="p-1.5 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors"
title="Mes siguiente"
>
<ChevronRight size={18} />
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-px mb-px">
{DAYS_ES.map(day => (
<div key={day} className="text-center text-[10px] font-semibold text-neutral-500 uppercase tracking-widest py-2">
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-px flex-1 bg-neutral-800/30 rounded-xl overflow-hidden border border-neutral-800/50">
{calendarDays.map(({ date, isCurrentMonth }, idx) => {
const dateKey = toDateKey(date);
const dayPieces = piecesByDate[dateKey] || [];
const today = isToday(date);
const isDragOver = dragOverDate === dateKey;
return (
<div
key={idx}
className={`min-h-[100px] p-1.5 flex flex-col transition-colors ${
isCurrentMonth
? 'bg-neutral-950/80'
: 'bg-neutral-950/40'
} ${isDragOver ? 'bg-violet-950/30 ring-1 ring-inset ring-violet-500/40' : ''}`}
onDragOver={(e) => handleDragOver(e, dateKey)}
onDragLeave={() => setDragOverDate(null)}
onDrop={(e) => handleDrop(e, dateKey)}
>
{/* Day number */}
<div className="flex items-center justify-between mb-1">
<span
className={`text-[11px] font-semibold w-6 h-6 flex items-center justify-center rounded-full transition-colors ${
today
? 'bg-violet-600 text-white'
: isCurrentMonth
? 'text-neutral-300'
: 'text-neutral-700'
}`}
>
{date.getDate()}
</span>
{isCurrentMonth && (
<button
onClick={() => onCreatePiece(dateKey)}
className="w-4 h-4 rounded flex items-center justify-center text-neutral-700 hover:text-violet-400 hover:bg-violet-600/10 transition-all opacity-0 hover:opacity-100 focus:opacity-100"
title="Crear contenido en este día"
>
<Plus size={10} />
</button>
)}
</div>
{/* Content pieces */}
<div className="space-y-0.5 flex-1 overflow-y-auto custom-scrollbar">
{dayPieces.slice(0, 3).map(piece => (
<ContentCard
key={piece.id}
piece={piece}
pillar={pillars.find(p => p.id === piece.pillarId)}
onClick={onPieceClick}
compact
draggable
onDragStart={handleDragStart}
/>
))}
{dayPieces.length > 3 && (
<span className="text-[9px] text-neutral-600 font-mono px-1">
+{dayPieces.length - 3} más
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
+125
View File
@@ -0,0 +1,125 @@
import React from 'react';
import { ContentPiece, ContentPillar } from '../../types';
import { StatusBadge } from './StatusBadge';
import { PlatformIcons } from './PlatformIcons';
import { GripVertical, Calendar, MessageSquare } from 'lucide-react';
interface ContentCardProps {
piece: ContentPiece;
pillar?: ContentPillar;
onClick: (piece: ContentPiece) => void;
compact?: boolean;
draggable?: boolean;
onDragStart?: (e: React.DragEvent, piece: ContentPiece) => void;
}
/**
* Card component for a single content piece.
* Used across Calendar, Grid, and List views.
* Supports drag-and-drop for reorganization.
*/
export const ContentCard: React.FC<ContentCardProps> = ({
piece,
pillar,
onClick,
compact = false,
draggable = false,
onDragStart,
}) => {
if (compact) {
return (
<button
onClick={() => onClick(piece)}
draggable={draggable}
onDragStart={(e) => onDragStart?.(e, piece)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg bg-neutral-900/60 border border-neutral-800/50 hover:border-neutral-700 hover:bg-neutral-800/50 transition-all text-left group cursor-pointer"
title={piece.title}
>
{/* Pillar color dot */}
{pillar && (
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: pillar.color }}
/>
)}
<span className="text-[11px] font-medium text-neutral-300 truncate flex-1">
{piece.title}
</span>
<PlatformIcons platforms={piece.platforms} size="sm" max={2} />
</button>
);
}
return (
<div
onClick={() => onClick(piece)}
draggable={draggable}
onDragStart={(e) => onDragStart?.(e, piece)}
className="group bg-neutral-900/60 backdrop-blur-sm border border-neutral-800/50 rounded-xl p-4 hover:border-neutral-700 hover:bg-neutral-800/40 transition-all cursor-pointer relative overflow-hidden"
>
{/* Pillar color bar */}
{pillar && (
<div
className="absolute top-0 left-0 w-full h-0.5"
style={{ backgroundColor: pillar.color }}
/>
)}
{/* Drag handle */}
{draggable && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-neutral-600 cursor-grab">
<GripVertical size={14} />
</div>
)}
<div className="space-y-2.5">
{/* Title */}
<h4 className="text-sm font-semibold text-white leading-tight line-clamp-2 pr-4">
{piece.title}
</h4>
{/* Status + Pillar */}
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={piece.status} />
{pillar && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full"
style={{
color: pillar.color,
backgroundColor: `${pillar.color}15`,
}}
>
{pillar.name}
</span>
)}
</div>
{/* Description preview */}
{piece.description && (
<p className="text-[11px] text-neutral-500 line-clamp-2 leading-relaxed">
{piece.description}
</p>
)}
{/* Footer: platforms + date */}
<div className="flex items-center justify-between pt-1 border-t border-neutral-800/30">
<PlatformIcons platforms={piece.platforms} size="sm" />
<div className="flex items-center gap-2">
{piece.scheduledDate && (
<span className="flex items-center gap-1 text-[10px] text-neutral-500 font-mono">
<Calendar size={10} />
{new Date(piece.scheduledDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
})}
</span>
)}
{piece.notes && (
<MessageSquare size={10} className="text-neutral-600" title="Tiene notas" />
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,333 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Trash2, ExternalLink, Calendar, Clock, Hash, FileText, StickyNote } from 'lucide-react';
import { ContentPiece, ContentPillar, ContentStatus, Platform, Project } from '../../types';
import { StatusBadge } from './StatusBadge';
import { PlatformSelector } from './PlatformIcons';
import { ALL_STATUSES, STATUS_CONFIG } from '../../data/defaults';
interface ContentDetailModalProps {
piece: ContentPiece | null;
pillars: ContentPillar[];
projects: Project[];
onSave: (piece: ContentPiece) => void;
onDelete: (id: string) => void;
onClose: () => void;
onOpenProject?: (projectId: string) => void;
}
/**
* Modal for creating/editing a content piece.
* Contains all fields: title, description, status, pillar, platforms,
* scheduled date/time, caption, hashtags, and notes.
*/
export const ContentDetailModal: React.FC<ContentDetailModalProps> = ({
piece,
pillars,
projects,
onSave,
onDelete,
onClose,
onOpenProject,
}) => {
const isNew = !piece;
const [form, setForm] = useState<ContentPiece>(() => {
if (piece) return { ...piece };
return {
id: `content-${Date.now()}`,
companyId: '',
title: '',
status: 'idea' as ContentStatus,
platforms: ['instagram'] as Platform[],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
// Reset form when piece changes
useEffect(() => {
if (piece) setForm({ ...piece });
}, [piece?.id]);
const update = <K extends keyof ContentPiece>(key: K, value: ContentPiece[K]) => {
setForm(prev => ({ ...prev, [key]: value, updatedAt: new Date().toISOString() }));
};
const handleSave = () => {
if (!form.title.trim()) return;
onSave(form);
};
const handleHashtagInput = (raw: string) => {
const tags = raw
.split(/[,\s]+/)
.map(t => t.replace(/^#/, '').trim())
.filter(Boolean);
update('hashtags', tags);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] overflow-hidden flex flex-col animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800">
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-violet-500 animate-pulse" />
<h3 className="text-sm font-bold text-white">
{isNew ? 'Nueva Pieza de Contenido' : 'Editar Contenido'}
</h3>
</div>
<button
onClick={onClose}
className="p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-800 rounded-lg transition-colors"
title="Cerrar"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-5 custom-scrollbar">
{/* Title */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Título *
</label>
<input
type="text"
value={form.title}
onChange={(e) => update('title', e.target.value)}
placeholder="¿De qué trata este contenido?"
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-sm text-white font-medium placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all"
autoFocus
/>
</div>
{/* Status + Pillar row */}
<div className="grid grid-cols-2 gap-4">
{/* Status */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Estado
</label>
<div className="flex flex-wrap gap-1">
{ALL_STATUSES.map(s => {
const cfg = STATUS_CONFIG[s];
const isActive = form.status === s;
return (
<button
key={s}
onClick={() => update('status', s)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400 hover:border-neutral-700'
}`}
style={
isActive
? { backgroundColor: cfg.bgColor, borderColor: `${cfg.color}50`, color: cfg.color }
: undefined
}
title={cfg.label}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cfg.color }} />
{cfg.label}
</button>
);
})}
</div>
</div>
{/* Pillar */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Pilar de Contenido
</label>
<div className="flex flex-wrap gap-1">
<button
onClick={() => update('pillarId', undefined)}
className={`px-2 py-1.5 rounded-lg text-[10px] font-medium transition-all border ${
!form.pillarId
? 'bg-neutral-800 border-neutral-700 text-neutral-300'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400'
}`}
>
Sin pilar
</button>
{pillars.map(p => {
const isActive = form.pillarId === p.id;
return (
<button
key={p.id}
onClick={() => update('pillarId', p.id)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400'
}`}
style={
isActive
? { backgroundColor: `${p.color}15`, borderColor: `${p.color}50`, color: p.color }
: undefined
}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: p.color }} />
{p.name}
</button>
);
})}
</div>
</div>
</div>
{/* Platforms */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Plataformas Destino
</label>
<PlatformSelector
selected={form.platforms}
onChange={(platforms) => update('platforms', platforms)}
/>
</div>
{/* Schedule */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<Calendar size={10} /> Fecha Programada
</label>
<input
type="date"
value={form.scheduledDate || ''}
onChange={(e) => update('scheduledDate', e.target.value || undefined)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-violet-500/50 transition-all [color-scheme:dark]"
/>
</div>
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<Clock size={10} /> Hora
</label>
<input
type="time"
value={form.scheduledTime || ''}
onChange={(e) => update('scheduledTime', e.target.value || undefined)}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-violet-500/50 transition-all [color-scheme:dark]"
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<FileText size={10} /> Descripción
</label>
<textarea
value={form.description || ''}
onChange={(e) => update('description', e.target.value)}
placeholder="Describe el contenido, contexto, o idea..."
rows={3}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all resize-none"
/>
</div>
{/* Caption */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5">
Caption / Copy del Post
</label>
<textarea
value={form.caption || ''}
onChange={(e) => update('caption', e.target.value)}
placeholder="El texto que acompañará la publicación..."
rows={3}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all resize-none font-mono"
/>
</div>
{/* Hashtags */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<Hash size={10} /> Hashtags
</label>
<input
type="text"
value={(form.hashtags || []).map(t => `#${t}`).join(' ')}
onChange={(e) => handleHashtagInput(e.target.value)}
placeholder="#marketing #socialmedia #brand"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-all font-mono"
/>
</div>
{/* Notes */}
<div>
<label className="block text-[10px] font-semibold text-neutral-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
<StickyNote size={10} /> Notas Internas
</label>
<textarea
value={form.notes || ''}
onChange={(e) => update('notes', e.target.value)}
placeholder="Notas para el equipo..."
rows={2}
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-4 py-3 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all resize-none"
/>
</div>
{/* Linked Project */}
{form.projectId && (
<div className="bg-neutral-800/30 border border-neutral-800 rounded-xl p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<ExternalLink size={12} className="text-violet-400" />
<span className="text-xs text-neutral-300 font-medium">
Proyecto vinculado: {projects.find(p => p.id === form.projectId)?.name || form.projectId}
</span>
</div>
{onOpenProject && (
<button
onClick={() => onOpenProject(form.projectId!)}
className="text-[10px] text-violet-400 hover:text-violet-300 font-medium"
title="Abrir en Studio"
>
Abrir en Studio
</button>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-neutral-800 bg-neutral-900/80">
<div>
{!isNew && (
<button
onClick={() => onDelete(form.id)}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-neutral-500 hover:text-rose-400 rounded-lg hover:bg-rose-950/20 transition-all"
title="Eliminar contenido"
>
<Trash2 size={13} /> Eliminar
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
disabled={!form.title.trim()}
className="flex items-center gap-1.5 px-4 py-2 text-xs font-semibold bg-violet-600 hover:bg-violet-500 text-white rounded-lg transition-colors shadow-lg shadow-violet-900/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Save size={13} /> {isNew ? 'Crear' : 'Guardar'}
</button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,164 @@
import React from 'react';
import { ContentStatus, Platform, ContentPillar } from '../../types';
import { STATUS_CONFIG, PLATFORM_CONFIG, ALL_STATUSES, ALL_PLATFORMS } from '../../data/defaults';
import { Filter, X } from 'lucide-react';
interface ContentFiltersProps {
pillars: ContentPillar[];
selectedPillar: string | null;
onPillarChange: (id: string | null) => void;
selectedStatus: ContentStatus | null;
onStatusChange: (status: ContentStatus | null) => void;
selectedPlatform: Platform | null;
onPlatformChange: (platform: Platform | null) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
}
/**
* Filter bar for the content grid.
* Allows filtering by pillar, status, platform, and free-text search.
*/
export const ContentFilters: React.FC<ContentFiltersProps> = ({
pillars,
selectedPillar,
onPillarChange,
selectedStatus,
onStatusChange,
selectedPlatform,
onPlatformChange,
searchQuery,
onSearchChange,
}) => {
const hasFilters = selectedPillar || selectedStatus || selectedPlatform || searchQuery;
return (
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-[320px]">
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Buscar contenido..."
className="w-full bg-neutral-900/60 border border-neutral-800 rounded-lg px-3 py-2 pl-8 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/30 transition-all"
/>
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-600" />
</div>
{/* Pillar filter */}
<div className="flex items-center gap-1">
<button
onClick={() => onPillarChange(null)}
className={`px-2 py-1.5 rounded-lg text-[10px] font-semibold transition-all border ${
!selectedPillar
? 'bg-violet-600/15 border-violet-500/30 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
title="Todos los pilares"
>
Todos
</button>
{pillars.map((p) => (
<button
key={p.id}
onClick={() => onPillarChange(selectedPillar === p.id ? null : p.id)}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-semibold transition-all border ${
selectedPillar === p.id
? 'border-opacity-60 text-white'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
style={
selectedPillar === p.id
? { backgroundColor: `${p.color}20`, borderColor: `${p.color}50`, color: p.color }
: undefined
}
title={`Pilar: ${p.name}`}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: p.color }} />
{p.name}
</button>
))}
</div>
{/* Clear all */}
{hasFilters && (
<button
onClick={() => {
onPillarChange(null);
onStatusChange(null);
onPlatformChange(null);
onSearchChange('');
}}
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-[10px] font-medium text-neutral-500 hover:text-rose-400 border border-neutral-800 hover:border-rose-500/30 transition-all"
title="Limpiar filtros"
>
<X size={10} /> Limpiar
</button>
)}
</div>
{/* Second row: Status + Platform */}
<div className="flex items-center gap-3 flex-wrap">
{/* Status chips */}
<div className="flex items-center gap-1">
<span className="text-[9px] font-semibold text-neutral-600 uppercase tracking-wider mr-1">Estado:</span>
{ALL_STATUSES.map((s) => {
const cfg = STATUS_CONFIG[s];
const isActive = selectedStatus === s;
return (
<button
key={s}
onClick={() => onStatusChange(isActive ? null : s)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400 hover:border-neutral-700'
}`}
style={
isActive
? { backgroundColor: cfg.bgColor, borderColor: `${cfg.color}50`, color: cfg.color }
: undefined
}
title={`Filtrar por: ${cfg.label}`}
>
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: cfg.color }} />
{cfg.label}
</button>
);
})}
</div>
{/* Platform chips */}
<div className="flex items-center gap-1">
<span className="text-[9px] font-semibold text-neutral-600 uppercase tracking-wider mr-1">Red:</span>
{ALL_PLATFORMS.map((p) => {
const cfg = PLATFORM_CONFIG[p];
const isActive = selectedPlatform === p;
return (
<button
key={p}
onClick={() => onPlatformChange(isActive ? null : p)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium transition-all border ${
isActive
? 'border-opacity-50'
: 'bg-neutral-950/40 border-neutral-800/50 text-neutral-600 hover:text-neutral-400 hover:border-neutral-700'
}`}
style={
isActive
? { backgroundColor: `${cfg.color}15`, borderColor: `${cfg.color}50`, color: cfg.color }
: undefined
}
title={`Filtrar por: ${cfg.label}`}
>
<span className="text-[10px]">{cfg.icon}</span>
{cfg.label}
</button>
);
})}
</div>
</div>
</div>
);
};
@@ -0,0 +1,321 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
CalendarDays, LayoutGrid, List, Plus, Settings2, Sparkles,
BarChart3, TrendingUp
} from 'lucide-react';
import {
ContentPiece, ContentPillar, ContentStatus, Platform, CompanyProfile
} from '../../types';
import { DEFAULT_PILLARS } from '../../data/defaults';
import { ContentFilters } from './ContentFilters';
import { CalendarView } from './CalendarView';
import { GridView } from './GridView';
import { ListView } from './ListView';
import { ContentDetailModal } from './ContentDetailModal';
import { PillarManager } from './PillarManager';
type ViewMode = 'calendar' | 'grid' | 'list';
interface ContentGridViewProps {
company: CompanyProfile;
pieces: ContentPiece[];
pillars: ContentPillar[];
onPiecesChange: (pieces: ContentPiece[]) => void;
onPillarsChange: (pillars: ContentPillar[]) => void;
onOpenProject: (projectId: string) => void;
}
/**
* Main content grid view with three visualization modes.
* Orchestrates Calendar, Grid, and List views with shared filters.
*/
export const ContentGridView: React.FC<ContentGridViewProps> = ({
company,
pieces,
pillars,
onPiecesChange,
onPillarsChange,
onOpenProject,
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('calendar');
const [showSettings, setShowSettings] = useState(false);
const [editingPiece, setEditingPiece] = useState<ContentPiece | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createDate, setCreateDate] = useState<string | undefined>();
// Grid view state
const [gridPlatform, setGridPlatform] = useState<Platform>('instagram');
// Filters
const [selectedPillar, setSelectedPillar] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<ContentStatus | null>(null);
const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Filter pieces
const filteredPieces = useMemo(() => {
return pieces.filter(p => {
if (selectedPillar && p.pillarId !== selectedPillar) return false;
if (selectedStatus && p.status !== selectedStatus) return false;
if (selectedPlatform && !p.platforms.includes(selectedPlatform)) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const matches =
p.title.toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
(p.caption || '').toLowerCase().includes(q);
if (!matches) return false;
}
return true;
});
}, [pieces, selectedPillar, selectedStatus, selectedPlatform, searchQuery]);
// Stats
const stats = useMemo(() => {
const total = pieces.length;
const scheduled = pieces.filter(p => p.status === 'scheduled').length;
const published = pieces.filter(p => p.status === 'published').length;
const thisWeek = pieces.filter(p => {
if (!p.scheduledDate) return false;
const d = new Date(p.scheduledDate);
const now = new Date();
const weekEnd = new Date(now);
weekEnd.setDate(weekEnd.getDate() + 7);
return d >= now && d <= weekEnd;
}).length;
return { total, scheduled, published, thisWeek };
}, [pieces]);
// Handlers
const handleCreatePiece = useCallback((date?: string) => {
setCreateDate(date);
setEditingPiece(null);
setShowCreateModal(true);
}, []);
const handleSavePiece = useCallback((piece: ContentPiece) => {
piece.companyId = company.id;
const exists = pieces.find(p => p.id === piece.id);
if (exists) {
onPiecesChange(pieces.map(p => p.id === piece.id ? piece : p));
} else {
// Apply the pre-set date if creating from calendar
if (createDate && !piece.scheduledDate) {
piece.scheduledDate = createDate;
if (piece.status === 'idea') piece.status = 'draft';
}
onPiecesChange([...pieces, piece]);
}
setEditingPiece(null);
setShowCreateModal(false);
setCreateDate(undefined);
}, [pieces, company.id, onPiecesChange, createDate]);
const handleDeletePiece = useCallback((id: string) => {
onPiecesChange(pieces.filter(p => p.id !== id));
setEditingPiece(null);
setShowCreateModal(false);
}, [pieces, onPiecesChange]);
const handleDropPiece = useCallback((pieceId: string, newDate: string) => {
onPiecesChange(pieces.map(p =>
p.id === pieceId
? { ...p, scheduledDate: newDate, updatedAt: new Date().toISOString() }
: p
));
}, [pieces, onPiecesChange]);
const handleStatusChange = useCallback((pieceId: string, newStatus: ContentStatus) => {
onPiecesChange(pieces.map(p =>
p.id === pieceId
? { ...p, status: newStatus, updatedAt: new Date().toISOString() }
: p
));
}, [pieces, onPiecesChange]);
const handlePieceClick = useCallback((piece: ContentPiece) => {
setEditingPiece(piece);
setShowCreateModal(true);
}, []);
return (
<div className="flex-1 overflow-hidden flex flex-col w-full relative bg-neutral-950">
{/* Background pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
/>
<div className="relative z-10 flex-1 flex flex-col overflow-hidden p-6">
{/* ═══ Header ═══ */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-violet-900/20">
<CalendarDays size={18} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-white tracking-tight">Malla de Contenidos</h1>
<p className="text-[11px] text-neutral-500">
{company.name} · {filteredPieces.length} de {pieces.length} piezas
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Stats mini-bar */}
<div className="hidden md:flex items-center gap-3 mr-3">
<StatPill label="Esta semana" value={stats.thisWeek} icon={<TrendingUp size={10} />} color="#a78bfa" />
<StatPill label="Programados" value={stats.scheduled} icon={<CalendarDays size={10} />} color="#60a5fa" />
<StatPill label="Publicados" value={stats.published} icon={<BarChart3 size={10} />} color="#22c55e" />
</div>
{/* Settings button */}
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-lg transition-all border ${
showSettings
? 'bg-violet-600/15 border-violet-500/30 text-violet-300'
: 'bg-neutral-900/60 border-neutral-800 text-neutral-500 hover:text-white hover:border-neutral-700'
}`}
title="Configurar Pilares"
>
<Settings2 size={16} />
</button>
{/* New content CTA */}
<button
onClick={() => handleCreatePiece()}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white rounded-xl text-xs font-semibold transition-all shadow-lg shadow-violet-900/20 hover:shadow-violet-900/40 hover:scale-[1.02] active:scale-[0.98]"
>
<Plus size={14} /> Nuevo Contenido
</button>
</div>
</div>
{/* ═══ Settings Panel (Pillar Manager) ═══ */}
{showSettings && (
<div className="mb-5 bg-neutral-900/40 border border-neutral-800/50 rounded-xl p-4 animate-in fade-in-0 slide-in-from-top-2 duration-200">
<PillarManager pillars={pillars} onChange={onPillarsChange} />
</div>
)}
{/* ═══ View Mode Toggle + Filters ═══ */}
<div className="flex items-start justify-between gap-4 mb-5">
{/* Filters */}
<div className="flex-1 min-w-0">
<ContentFilters
pillars={pillars}
selectedPillar={selectedPillar}
onPillarChange={setSelectedPillar}
selectedStatus={selectedStatus}
onStatusChange={setSelectedStatus}
selectedPlatform={selectedPlatform}
onPlatformChange={setSelectedPlatform}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
</div>
{/* View toggle */}
<div className="flex bg-neutral-900 border border-neutral-800 rounded-xl p-0.5 shrink-0">
{([
{ id: 'calendar' as ViewMode, icon: <CalendarDays size={14} />, label: 'Calendario' },
{ id: 'grid' as ViewMode, icon: <LayoutGrid size={14} />, label: 'Grid' },
{ id: 'list' as ViewMode, icon: <List size={14} />, label: 'Lista' },
]).map(v => (
<button
key={v.id}
onClick={() => setViewMode(v.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
viewMode === v.id
? 'bg-neutral-800 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
title={v.label}
>
{v.icon}
<span className="hidden sm:inline">{v.label}</span>
</button>
))}
</div>
</div>
{/* ═══ View Content ═══ */}
<div className="flex-1 overflow-hidden">
{viewMode === 'calendar' && (
<CalendarView
pieces={filteredPieces}
pillars={pillars}
onPieceClick={handlePieceClick}
onCreatePiece={(date) => handleCreatePiece(date)}
onDropPiece={handleDropPiece}
/>
)}
{viewMode === 'grid' && (
<GridView
pieces={filteredPieces}
pillars={pillars}
onPieceClick={handlePieceClick}
platform={gridPlatform}
onPlatformChange={setGridPlatform}
/>
)}
{viewMode === 'list' && (
<ListView
pieces={filteredPieces}
pillars={pillars}
onPieceClick={handlePieceClick}
onStatusChange={handleStatusChange}
/>
)}
{/* Empty state */}
{filteredPieces.length === 0 && pieces.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-neutral-600">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-600/10 to-fuchsia-600/10 border border-violet-500/10 flex items-center justify-center mb-4">
<Sparkles size={28} className="text-violet-500/40" />
</div>
<h3 className="text-sm font-semibold text-neutral-400 mb-1">Tu malla está vacía</h3>
<p className="text-xs text-neutral-600 text-center max-w-xs mb-4">
Empieza a planificar tu contenido creando piezas y organizándolas en el calendario
</p>
<button
onClick={() => handleCreatePiece()}
className="flex items-center gap-1.5 px-4 py-2 bg-violet-600/15 hover:bg-violet-600/25 text-violet-400 text-xs font-semibold rounded-xl border border-violet-500/20 hover:border-violet-500/40 transition-all"
>
<Plus size={14} /> Crear primera pieza
</button>
</div>
)}
</div>
</div>
{/* ═══ Content Detail Modal ═══ */}
{showCreateModal && (
<ContentDetailModal
piece={editingPiece}
pillars={pillars}
projects={company.projects || []}
onSave={handleSavePiece}
onDelete={handleDeletePiece}
onClose={() => { setShowCreateModal(false); setEditingPiece(null); setCreateDate(undefined); }}
onOpenProject={onOpenProject}
/>
)}
</div>
);
};
/** Mini stat pill for the header */
const StatPill: React.FC<{ label: string; value: number; icon: React.ReactNode; color: string }> = ({
label, value, icon, color,
}) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-lg border text-[10px] font-medium"
style={{ borderColor: `${color}20`, color, backgroundColor: `${color}08` }}
>
{icon}
<span className="font-bold">{value}</span>
<span className="opacity-60">{label}</span>
</div>
);
+162
View File
@@ -0,0 +1,162 @@
import React, { useMemo, useCallback } from 'react';
import { ContentPiece, ContentPillar, Platform } from '../../types';
import { PlatformIcons } from './PlatformIcons';
import { StatusBadge } from './StatusBadge';
import { Image as ImageIcon, Video, Instagram } from 'lucide-react';
interface GridViewProps {
pieces: ContentPiece[];
pillars: ContentPillar[];
onPieceClick: (piece: ContentPiece) => void;
platform: Platform;
onPlatformChange: (platform: Platform) => void;
}
/**
* Visual grid view inspired by Later/Instagram feed planner.
* Shows content as a 3-column grid preview mimicking how it will look on a social feed.
*/
export const GridView: React.FC<GridViewProps> = ({
pieces,
pillars,
onPieceClick,
platform,
onPlatformChange,
}) => {
// Filter pieces that target the selected platform and are scheduled/published
const gridPieces = useMemo(() => {
return pieces
.filter(p => p.platforms.includes(platform))
.sort((a, b) => {
// Sort by scheduled date, most recent first
const dateA = a.scheduledDate || a.createdAt;
const dateB = b.scheduledDate || b.createdAt;
return dateB.localeCompare(dateA);
});
}, [pieces, platform]);
const columns = platform === 'tiktok' || platform === 'youtube' ? 2 : 3;
return (
<div className="flex flex-col h-full">
{/* Platform selector */}
<div className="flex items-center gap-3 pb-4">
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest">
Vista de Feed:
</span>
<div className="flex gap-1">
{(['instagram', 'tiktok', 'facebook', 'linkedin'] as Platform[]).map(p => {
const isActive = platform === p;
return (
<button
key={p}
onClick={() => onPlatformChange(p)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all border ${
isActive
? 'bg-violet-600/15 border-violet-500/30 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
title={`Vista de ${p}`}
>
{p === 'instagram' && '📸'}
{p === 'tiktok' && '🎵'}
{p === 'facebook' && '📘'}
{p === 'linkedin' && '💼'}
{p.charAt(0).toUpperCase() + p.slice(1)}
</button>
);
})}
</div>
</div>
{/* Feed preview container */}
<div className="flex-1 flex justify-center overflow-y-auto">
<div
className="bg-neutral-900/30 border border-neutral-800/50 rounded-2xl p-4 max-w-lg w-full"
style={{ maxWidth: columns === 2 ? '380px' : '480px' }}
>
{/* Fake profile header */}
<div className="flex items-center gap-3 mb-4 px-1">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500" />
<div>
<p className="text-xs font-semibold text-white">@mi_marca</p>
<p className="text-[10px] text-neutral-500">{gridPieces.length} publicaciones</p>
</div>
</div>
{/* Grid */}
{gridPieces.length > 0 ? (
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}
>
{gridPieces.map(piece => {
const pillar = pillars.find(p => p.id === piece.pillarId);
return (
<button
key={piece.id}
onClick={() => onPieceClick(piece)}
className="relative aspect-square bg-neutral-800 rounded-sm overflow-hidden group hover:opacity-90 transition-all"
>
{/* Thumbnail or placeholder */}
{piece.thumbnailUrl ? (
<img
src={piece.thumbnailUrl}
alt={piece.title}
className="w-full h-full object-cover"
/>
) : (
<div
className="w-full h-full flex flex-col items-center justify-center p-2"
style={{ backgroundColor: pillar ? `${pillar.color}15` : '#1a1a2e' }}
>
<div className="text-neutral-600 mb-1">
{piece.projectId ? <Video size={16} /> : <ImageIcon size={16} />}
</div>
<span className="text-[8px] text-neutral-500 text-center line-clamp-2 leading-tight">
{piece.title}
</span>
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 p-2">
<span className="text-[10px] font-semibold text-white text-center line-clamp-2">
{piece.title}
</span>
<StatusBadge status={piece.status} size="sm" />
{piece.scheduledDate && (
<span className="text-[9px] text-neutral-400 font-mono">
{new Date(piece.scheduledDate).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
})}
</span>
)}
</div>
{/* Pillar indicator */}
{pillar && (
<div
className="absolute top-1 right-1 w-2 h-2 rounded-full ring-1 ring-black/30"
style={{ backgroundColor: pillar.color }}
/>
)}
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-neutral-600">
<Instagram size={32} className="mb-3 opacity-30" />
<p className="text-xs font-medium">No hay contenido para {platform}</p>
<p className="text-[10px] text-neutral-700 mt-1">
Crea piezas de contenido y asígnalas a esta plataforma
</p>
</div>
)}
</div>
</div>
</div>
);
};
+206
View File
@@ -0,0 +1,206 @@
import React, { useMemo, useCallback, useState } from 'react';
import { ContentPiece, ContentPillar, ContentStatus } from '../../types';
import { ContentCard } from './ContentCard';
import { STATUS_CONFIG, ALL_STATUSES } from '../../data/defaults';
import { Columns3, List as ListIcon } from 'lucide-react';
interface ListViewProps {
pieces: ContentPiece[];
pillars: ContentPillar[];
onPieceClick: (piece: ContentPiece) => void;
onStatusChange: (pieceId: string, newStatus: ContentStatus) => void;
}
type ListMode = 'kanban' | 'list';
/**
* List/Kanban view for content pieces.
* Kanban: columns per status with drag-and-drop between columns.
* List: simple scrollable list grouped by status.
*/
export const ListView: React.FC<ListViewProps> = ({
pieces,
pillars,
onPieceClick,
onStatusChange,
}) => {
const [mode, setMode] = useState<ListMode>('kanban');
const [dragOverStatus, setDragOverStatus] = useState<ContentStatus | null>(null);
// Group pieces by status
const groupedPieces = useMemo(() => {
const map: Record<ContentStatus, ContentPiece[]> = {
'idea': [],
'draft': [],
'in-review': [],
'approved': [],
'scheduled': [],
'published': [],
};
pieces.forEach(p => {
map[p.status].push(p);
});
return map;
}, [pieces]);
const handleDragStart = useCallback((e: React.DragEvent, piece: ContentPiece) => {
e.dataTransfer.setData('text/piece-id', piece.id);
e.dataTransfer.effectAllowed = 'move';
}, []);
const handleDragOver = useCallback((e: React.DragEvent, status: ContentStatus) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverStatus(status);
}, []);
const handleDrop = useCallback((e: React.DragEvent, status: ContentStatus) => {
e.preventDefault();
const pieceId = e.dataTransfer.getData('text/piece-id');
if (pieceId) {
onStatusChange(pieceId, status);
}
setDragOverStatus(null);
}, [onStatusChange]);
if (mode === 'list') {
return (
<div className="flex flex-col h-full">
{/* Mode toggle */}
<div className="flex items-center justify-end pb-3">
<ModeToggle mode={mode} setMode={setMode} />
</div>
<div className="flex-1 overflow-y-auto space-y-6 pr-1 custom-scrollbar">
{ALL_STATUSES.map(status => {
const statusPieces = groupedPieces[status];
if (statusPieces.length === 0) return null;
const cfg = STATUS_CONFIG[status];
return (
<div key={status}>
<div className="flex items-center gap-2 mb-2.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: cfg.color }} />
<h4 className="text-xs font-semibold uppercase tracking-widest" style={{ color: cfg.color }}>
{cfg.label}
</h4>
<span className="text-[10px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{statusPieces.length}
</span>
</div>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
{statusPieces.map(piece => (
<ContentCard
key={piece.id}
piece={piece}
pillar={pillars.find(p => p.id === piece.pillarId)}
onClick={onPieceClick}
/>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
// Kanban mode
return (
<div className="flex flex-col h-full">
{/* Mode toggle */}
<div className="flex items-center justify-end pb-3">
<ModeToggle mode={mode} setMode={setMode} />
</div>
{/* Kanban columns */}
<div className="flex-1 flex gap-3 overflow-x-auto pb-2 custom-scrollbar">
{ALL_STATUSES.map(status => {
const cfg = STATUS_CONFIG[status];
const statusPieces = groupedPieces[status];
const isDragOver = dragOverStatus === status;
return (
<div
key={status}
className={`flex-shrink-0 w-[260px] flex flex-col rounded-xl border transition-colors ${
isDragOver
? 'border-violet-500/40 bg-violet-950/20'
: 'border-neutral-800/50 bg-neutral-900/30'
}`}
onDragOver={(e) => handleDragOver(e, status)}
onDragLeave={() => setDragOverStatus(null)}
onDrop={(e) => handleDrop(e, status)}
>
{/* Column header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-neutral-800/30">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: cfg.color }}
/>
<span
className="text-[11px] font-semibold uppercase tracking-wider"
style={{ color: cfg.color }}
>
{cfg.label}
</span>
<span className="ml-auto text-[10px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{statusPieces.length}
</span>
</div>
{/* Column body */}
<div className="flex-1 p-2 space-y-2 overflow-y-auto custom-scrollbar min-h-[120px]">
{statusPieces.map(piece => (
<ContentCard
key={piece.id}
piece={piece}
pillar={pillars.find(p => p.id === piece.pillarId)}
onClick={onPieceClick}
draggable
onDragStart={handleDragStart}
/>
))}
{statusPieces.length === 0 && (
<div className="flex items-center justify-center h-20 text-neutral-700 text-[10px] font-medium border border-dashed border-neutral-800 rounded-lg">
Arrastra aquí
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
/** Toggle between kanban and list modes */
const ModeToggle: React.FC<{ mode: ListMode; setMode: (m: ListMode) => void }> = ({ mode, setMode }) => (
<div className="flex bg-neutral-900 border border-neutral-800 rounded-lg p-0.5">
<button
onClick={() => setMode('kanban')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
mode === 'kanban'
? 'bg-neutral-800 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
title="Vista Kanban"
>
<Columns3 size={12} /> Kanban
</button>
<button
onClick={() => setMode('list')}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-medium transition-all ${
mode === 'list'
? 'bg-neutral-800 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
title="Vista Lista"
>
<ListIcon size={12} /> Lista
</button>
</div>
);
@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { ContentPillar } from '../../types';
import { Plus, Trash2, GripVertical, Pencil, Check, X } from 'lucide-react';
interface PillarManagerProps {
pillars: ContentPillar[];
onChange: (pillars: ContentPillar[]) => void;
}
const PRESET_COLORS = [
'#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#22c55e',
'#06b6d4', '#f97316', '#6366f1', '#14b8a6', '#e11d48',
];
/**
* CRUD manager for content pillars.
* Allows creating, editing, and deleting pillars with color pickers.
*/
export const PillarManager: React.FC<PillarManagerProps> = ({ pillars, onChange }) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState(PRESET_COLORS[0]);
const [showAdd, setShowAdd] = useState(false);
const handleAdd = () => {
if (!newName.trim()) return;
const pillar: ContentPillar = {
id: `pillar-${Date.now()}`,
name: newName.trim(),
color: newColor,
};
onChange([...pillars, pillar]);
setNewName('');
setNewColor(PRESET_COLORS[(pillars.length + 1) % PRESET_COLORS.length]);
setShowAdd(false);
};
const handleDelete = (id: string) => {
onChange(pillars.filter(p => p.id !== id));
};
const handleStartEdit = (pillar: ContentPillar) => {
setEditingId(pillar.id);
setEditName(pillar.name);
};
const handleSaveEdit = (id: string) => {
if (!editName.trim()) return;
onChange(pillars.map(p => p.id === id ? { ...p, name: editName.trim() } : p));
setEditingId(null);
};
const handleColorChange = (id: string, color: string) => {
onChange(pillars.map(p => p.id === id ? { ...p, color } : p));
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-widest flex items-center gap-2">
Pilares de Contenido
<span className="text-[10px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{pillars.length}
</span>
</h4>
<button
onClick={() => setShowAdd(!showAdd)}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-[10px] font-medium text-violet-400 hover:text-violet-300 bg-violet-600/10 hover:bg-violet-600/20 border border-violet-500/20 hover:border-violet-500/40 transition-all"
title="Agregar pilar"
>
<Plus size={12} /> Nuevo Pilar
</button>
</div>
{/* Add form */}
{showAdd && (
<div className="bg-neutral-900/60 border border-violet-500/20 rounded-xl p-3 space-y-3 animate-in fade-in-0 slide-in-from-top-2 duration-200">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
placeholder="Nombre del pilar (ej. Educativo)"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:outline-none focus:border-violet-500/50 transition-all"
autoFocus
/>
<div className="flex items-center gap-2">
<span className="text-[10px] text-neutral-500 shrink-0">Color:</span>
<div className="flex gap-1 flex-wrap">
{PRESET_COLORS.map(c => (
<button
key={c}
onClick={() => setNewColor(c)}
className={`w-5 h-5 rounded-full border-2 transition-all ${
newColor === c ? 'border-white scale-110' : 'border-transparent hover:border-neutral-600'
}`}
style={{ backgroundColor: c }}
title={c}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowAdd(false)}
className="px-3 py-1.5 text-[10px] font-medium text-neutral-500 hover:text-white rounded-lg hover:bg-neutral-800 transition-colors"
>
Cancelar
</button>
<button
onClick={handleAdd}
disabled={!newName.trim()}
className="px-3 py-1.5 text-[10px] font-semibold bg-violet-600 hover:bg-violet-500 text-white rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1"
>
<Check size={12} /> Crear
</button>
</div>
</div>
)}
{/* Pillar list */}
<div className="space-y-1.5">
{pillars.map(pillar => (
<div
key={pillar.id}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-neutral-900/40 border border-neutral-800/50 hover:border-neutral-700 group transition-all"
>
{/* Color dot (editable) */}
<div className="relative">
<input
type="color"
value={pillar.color}
onChange={(e) => handleColorChange(pillar.id, e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-4 h-4"
title="Cambiar color"
/>
<div
className="w-3 h-3 rounded-full cursor-pointer ring-2 ring-transparent hover:ring-white/30 transition-all"
style={{ backgroundColor: pillar.color }}
/>
</div>
{/* Name (editable) */}
{editingId === pillar.id ? (
<div className="flex items-center gap-1 flex-1">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(pillar.id);
if (e.key === 'Escape') setEditingId(null);
}}
className="flex-1 bg-neutral-950 border border-neutral-700 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-violet-500"
autoFocus
/>
<button
onClick={() => handleSaveEdit(pillar.id)}
title="Guardar"
className="p-1 text-emerald-400 hover:text-emerald-300"
>
<Check size={12} />
</button>
<button
onClick={() => setEditingId(null)}
title="Cancelar"
className="p-1 text-neutral-500 hover:text-white"
>
<X size={12} />
</button>
</div>
) : (
<>
<span className="flex-1 text-xs font-medium text-neutral-300">{pillar.name}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleStartEdit(pillar)}
title="Editar pilar"
className="p-1 text-neutral-500 hover:text-violet-400 transition-colors"
>
<Pencil size={11} />
</button>
<button
onClick={() => handleDelete(pillar.id)}
title="Eliminar pilar"
className="p-1 text-neutral-500 hover:text-rose-400 transition-colors"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
))}
{pillars.length === 0 && (
<div className="text-center py-4 text-neutral-600 text-xs">
No hay pilares definidos. Crea uno para organizar tu contenido.
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,101 @@
import React from 'react';
import { Platform } from '../../types';
import { PLATFORM_CONFIG } from '../../data/defaults';
interface PlatformIconsProps {
platforms: Platform[];
size?: 'sm' | 'md';
max?: number;
}
/**
* Renders a row of social platform emoji icons with tooltips.
* Truncates to `max` items and shows "+N" overflow.
*/
export const PlatformIcons: React.FC<PlatformIconsProps> = ({
platforms,
size = 'sm',
max = 4,
}) => {
const visible = platforms.slice(0, max);
const overflow = platforms.length - max;
return (
<div className="flex items-center gap-0.5">
{visible.map(p => {
const cfg = PLATFORM_CONFIG[p];
return (
<span
key={p}
title={cfg.label}
className={`inline-flex items-center justify-center rounded-md transition-transform hover:scale-110 ${
size === 'sm' ? 'w-5 h-5 text-[11px]' : 'w-6 h-6 text-sm'
}`}
style={{ backgroundColor: `${cfg.color}15` }}
>
{cfg.icon}
</span>
);
})}
{overflow > 0 && (
<span
className={`inline-flex items-center justify-center rounded-md bg-neutral-800 text-neutral-500 font-mono font-semibold ${
size === 'sm' ? 'w-5 h-5 text-[9px]' : 'w-6 h-6 text-[10px]'
}`}
title={platforms.slice(max).map(p => PLATFORM_CONFIG[p].label).join(', ')}
>
+{overflow}
</span>
)}
</div>
);
};
interface PlatformSelectorProps {
selected: Platform[];
onChange: (platforms: Platform[]) => void;
}
/**
* Multi-select toggle for choosing target platforms.
*/
export const PlatformSelector: React.FC<PlatformSelectorProps> = ({ selected, onChange }) => {
const toggle = (p: Platform) => {
onChange(
selected.includes(p)
? selected.filter(x => x !== p)
: [...selected, p]
);
};
return (
<div className="flex flex-wrap gap-1.5">
{(Object.entries(PLATFORM_CONFIG) as [Platform, typeof PLATFORM_CONFIG[Platform]][]).map(
([key, cfg]) => {
const isActive = selected.includes(key);
return (
<button
key={key}
type="button"
onClick={() => toggle(key)}
title={cfg.label}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all ${
isActive
? 'border-opacity-60 text-white shadow-sm'
: 'bg-neutral-950/50 border-neutral-800 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300'
}`}
style={
isActive
? { backgroundColor: `${cfg.color}20`, borderColor: `${cfg.color}60`, color: cfg.color }
: undefined
}
>
<span className="text-sm">{cfg.icon}</span>
{cfg.label}
</button>
);
}
)}
</div>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { ContentStatus } from '../../types';
import { STATUS_CONFIG } from '../../data/defaults';
interface StatusBadgeProps {
status: ContentStatus;
size?: 'sm' | 'md';
onClick?: () => void;
}
/**
* Color-coded badge for content workflow status.
* Displays the localized label with a matching pill color.
*/
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, size = 'sm', onClick }) => {
const config = STATUS_CONFIG[status];
return (
<span
onClick={onClick}
className={`inline-flex items-center gap-1 font-semibold rounded-full border transition-all select-none ${
onClick ? 'cursor-pointer hover:brightness-125' : ''
} ${
size === 'sm'
? 'text-[10px] px-2 py-0.5'
: 'text-xs px-2.5 py-1'
}`}
style={{
color: config.color,
backgroundColor: config.bgColor,
borderColor: `${config.color}30`,
}}
title={`Estado: ${config.label}`}
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: config.color }}
/>
{config.label}
</span>
);
};
+309
View File
@@ -0,0 +1,309 @@
/**
* BatchDataPanel Left panel for batch mode in ProductionForm.
*
* Sections:
* 1. Header with piece count + brand note
* 2. Multi-file background upload (defines N)
* 3. Text data table (columns = editable fields, rows = pieces)
* 4. CSV import button
*/
import React, { useRef, useCallback } from 'react';
import {
FileSpreadsheet, Upload, ImageIcon, AlertTriangle,
Trash2, Film, Video,
} from 'lucide-react';
import type { TemplateField, BatchPieceData, CompanyProfile } from '../../types';
interface BatchDataPanelProps {
pieces: BatchPieceData[];
editableSlots: { field: TemplateField; sceneId: string }[];
brand: CompanyProfile;
templateFormat: 'video' | 'image';
onSetBackgrounds: (files: File[]) => void;
onUpdateField: (index: number, fieldId: string, value: string) => void;
onImportCSV: (file: File) => Promise<{ matched: number; unmatched: number }>;
onRemovePiece: (index: number) => void;
backgroundFiles: File[];
}
/** Get only text-type editable slots (for table columns) */
function getTextSlots(editableSlots: BatchDataPanelProps['editableSlots']) {
return editableSlots.filter(s => s.field.type === 'text');
}
export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
pieces,
editableSlots,
brand,
templateFormat,
onSetBackgrounds,
onUpdateField,
onImportCSV,
onRemovePiece,
backgroundFiles,
}) => {
const bgInputRef = useRef<HTMLInputElement>(null);
const csvInputRef = useRef<HTMLInputElement>(null);
const textSlots = getTextSlots(editableSlots);
const isVideo = templateFormat === 'video';
const N = pieces.length;
// ─── Background upload handler ───
const handleBgUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Merge with existing files
const allFiles = [...backgroundFiles, ...files];
onSetBackgrounds(allFiles);
}
// Reset input so re-uploading same files triggers change
if (bgInputRef.current) bgInputRef.current.value = '';
}, [backgroundFiles, onSetBackgrounds]);
// ─── CSV import handler ───
const handleCSVUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
await onImportCSV(file);
if (csvInputRef.current) csvInputRef.current.value = '';
}, [onImportCSV]);
// ─── Max visible rows before "+N more" ───
const MAX_VISIBLE = 8;
const visiblePieces = pieces.slice(0, MAX_VISIBLE);
const overflowCount = Math.max(0, N - MAX_VISIBLE);
return (
<div className="flex flex-col h-full">
{/* ── Header ── */}
<div className="px-5 py-3 border-b border-neutral-800/30 bg-gradient-to-r from-violet-500/5 to-fuchsia-500/5 shrink-0">
<div className="flex items-center gap-2">
<FileSpreadsheet size={13} className="text-violet-400" />
<h2 className="text-xs font-bold text-white">Datos del lote</h2>
{N > 0 && (
<span className="text-[9px] text-violet-400 bg-violet-500/10 px-2 py-0.5 rounded-full font-bold">
{N} pieza{N !== 1 ? 's' : ''}
</span>
)}
</div>
<p className="text-[10px] text-neutral-500 mt-1">
El estilo de <span className="text-amber-400">{brand.name}</span> se aplica a todas.
</p>
</div>
{/* ── Scrollable content ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-4">
{/* ── Background Upload ── */}
<div className="space-y-1.5">
<label className="flex items-center gap-1.5 text-[11px] text-neutral-300 font-medium">
{isVideo
? <Video size={11} className="text-sky-400" />
: <ImageIcon size={11} className="text-sky-400" />}
{isVideo ? 'Videos de fondo' : 'Imágenes de fondo'}
<span className="text-red-400 text-[10px]">*</span>
</label>
<button
type="button"
onClick={() => bgInputRef.current?.click()}
title={isVideo ? 'Subir videos de fondo (define la cantidad de piezas)' : 'Subir imágenes de fondo (define la cantidad de piezas)'}
className={`w-full flex items-center gap-3 px-4 py-3 border-2 border-dashed rounded-lg transition-all cursor-pointer ${
N > 0
? 'border-violet-500/30 bg-violet-950/10 hover:border-violet-500/50'
: 'border-neutral-700 bg-neutral-800/30 hover:border-neutral-600'
}`}
>
<Upload size={16} className={N > 0 ? 'text-violet-400' : 'text-neutral-600'} />
<div className="text-left flex-1">
{N > 0 ? (
<span className="text-xs text-white font-medium">
{N} {isVideo ? 'video' : 'imagen'}{N !== 1 ? (isVideo ? 's' : 'es') : ''} cargada{N !== 1 ? 's' : ''}
</span>
) : (
<span className="text-xs text-neutral-500">
{isVideo ? 'Subir videos' : 'Subir imágenes'} (selección múltiple)
</span>
)}
</div>
{N > 0 && (
<span className="text-[9px] text-neutral-500">+ agregar</span>
)}
</button>
<input
ref={bgInputRef}
type="file"
accept={isVideo ? 'video/*' : 'image/*'}
multiple
className="hidden"
onChange={handleBgUpload}
/>
<p className="text-[9px] text-neutral-600">
Definen la cantidad de piezas.
</p>
</div>
{/* ── Text Data Table ── */}
{textSlots.length > 0 && N > 0 && (
<div className="space-y-2">
{/* Table header with CSV import */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{textSlots.map(({ field }) => (
<span key={field.id} className="flex items-center gap-1 text-[10px] text-neutral-400">
<span className="text-[11px] font-medium text-neutral-300">{field.label}</span>
{field.required && <span className="text-red-400 text-[8px]">*</span>}
</span>
)).reduce<React.ReactNode[]>((acc, el, i) => {
if (i > 0) acc.push(<span key={`sep-${i}`} className="text-neutral-700 text-[8px]">·</span>);
acc.push(el);
return acc;
}, [])}
</div>
<button
type="button"
onClick={() => csvInputRef.current?.click()}
title="Importar datos desde CSV"
className="flex items-center gap-1.5 px-2 py-1 rounded-md text-[9px] font-semibold text-amber-400 hover:bg-amber-500/10 transition-colors"
>
<FileSpreadsheet size={10} />
Importar CSV
</button>
<input
ref={csvInputRef}
type="file"
accept=".csv,.tsv,.txt"
className="hidden"
onChange={handleCSVUpload}
/>
</div>
{/* Data table */}
<div className="rounded-lg border border-neutral-800/60 overflow-hidden">
{/* Table header row */}
<div
className="grid gap-px bg-neutral-800/50 text-[9px] text-neutral-500 font-bold uppercase tracking-wider"
style={{
gridTemplateColumns: `36px 90px ${textSlots.map(() => '1fr').join(' ')} 28px`,
}}
>
<div className="bg-neutral-900/80 px-2 py-1.5 text-center">#</div>
<div className="bg-neutral-900/80 px-2 py-1.5">Fondo</div>
{textSlots.map(({ field }) => (
<div key={field.id} className="bg-neutral-900/80 px-2 py-1.5 truncate">
{field.label}
</div>
))}
<div className="bg-neutral-900/80 px-1 py-1.5" />
</div>
{/* Data rows */}
{visiblePieces.map((piece) => {
const hasErrors = Object.keys(piece.errors).length > 0;
return (
<div
key={piece.index}
className={`grid gap-px text-xs ${
hasErrors ? 'bg-red-500/5' : 'bg-neutral-800/20'
}`}
style={{
gridTemplateColumns: `36px 90px ${textSlots.map(() => '1fr').join(' ')} 28px`,
}}
>
{/* Row number */}
<div className="bg-neutral-900/60 px-2 py-1.5 text-center text-neutral-500 text-[10px] font-mono flex items-center justify-center">
{piece.index + 1}
</div>
{/* Background filename */}
<div className="bg-neutral-900/60 px-2 py-1.5 flex items-center gap-1 min-w-0">
<span className="text-[9px] text-neutral-400 truncate font-mono">
{piece.backgroundFilename}
</span>
</div>
{/* Text fields */}
{textSlots.map(({ field }) => {
const val = piece.fieldData[field.id] || '';
const err = piece.errors[field.id];
return (
<div key={field.id} className="bg-neutral-900/60 px-0.5 py-0.5 flex items-center">
<input
type="text"
value={val}
onChange={(e) => onUpdateField(piece.index, field.id, e.target.value)}
placeholder={field.content || field.label}
title={err || field.label}
className={`w-full bg-transparent px-1.5 py-1 rounded text-[10px] text-white placeholder-neutral-600 focus:outline-none focus:bg-neutral-800/50 transition-colors ${
err ? 'text-red-400 ring-1 ring-red-500/30' : ''
}`}
style={{ fontFamily: 'inherit' }}
/>
</div>
);
})}
{/* Delete row */}
<div className="bg-neutral-900/60 flex items-center justify-center">
<button
type="button"
onClick={() => onRemovePiece(piece.index)}
title="Quitar pieza"
className="p-0.5 text-neutral-600 hover:text-red-400 transition-colors"
>
<Trash2 size={10} />
</button>
</div>
</div>
);
})}
{/* Overflow indicator */}
{overflowCount > 0 && (
<div className="bg-neutral-900/40 px-3 py-2 text-center text-[10px] text-neutral-500">
+ {overflowCount} fila{overflowCount !== 1 ? 's' : ''}
</div>
)}
</div>
{/* Validation summary */}
{pieces.some(p => !p.isValid) && (
<div className="flex items-center gap-1.5 text-[9px] text-amber-400">
<AlertTriangle size={10} />
<span>
{pieces.filter(p => !p.isValid).length} pieza{pieces.filter(p => !p.isValid).length !== 1 ? 's' : ''} con datos faltantes
</span>
</div>
)}
</div>
)}
{/* ── Empty state (no text fields) ── */}
{textSlots.length === 0 && N > 0 && (
<div className="text-center py-4">
<p className="text-[10px] text-neutral-500">
Esta plantilla no tiene campos de texto editables.
</p>
<p className="text-[9px] text-neutral-600 mt-1">
Solo se varía el fondo en cada pieza.
</p>
</div>
)}
{/* ── Empty state (no backgrounds yet) ── */}
{N === 0 && (
<div className="text-center py-8">
<Upload size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">
Sube {isVideo ? 'videos' : 'imágenes'} de fondo para comenzar
</p>
<p className="text-[10px] text-neutral-600 mt-1">
La cantidad define cuántas piezas se generan.
</p>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,520 @@
/**
* BatchPreviewGrid Right panel for batch mode in ProductionForm.
*
* For IMAGE templates: shows a grid of thumbnails (each piece rendered).
* For VIDEO templates: shows a single preview player (not N players).
*
* Click on a thumbnail fullscreen carousel with prev/next navigation.
*/
import React, { useState, useMemo, useCallback, useRef } from 'react';
import {
X, ChevronLeft, ChevronRight, AlertTriangle, Eye,
} from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player';
import { BrandComposition } from '../BrandComposition';
import {
compileExpressToTimeline, getAspectDimensions, getTemplateDuration,
} from '../../utils/expressCompiler';
import type {
BatchPieceData, ExpressTemplate, CompanyProfile, DesignMD,
} from '../../types';
interface BatchPreviewGridProps {
pieces: BatchPieceData[];
template: ExpressTemplate;
brand: CompanyProfile;
designMD: DesignMD;
/** Active piece index for video single-preview mode */
activePieceIndex: number;
onActivePieceChange: (index: number) => void;
}
/**
* Compile a single piece into Remotion inputProps.
* Merges the piece's background URL into fieldData for the background field.
*/
function compilePiece(
piece: BatchPieceData,
template: ExpressTemplate,
designMD: DesignMD,
brand: CompanyProfile,
backgroundFieldId: string | null,
) {
// Build fieldData with the background injected
const fieldData: Record<string, string> = { ...piece.fieldData };
if (backgroundFieldId && piece.backgroundUrl) {
fieldData[backgroundFieldId] = piece.backgroundUrl;
}
const result = compileExpressToTimeline(template, fieldData, designMD, brand);
// Strip transitions for static preview
result.elements = result.elements.map(el => ({
...el,
transitionIn: undefined,
transitionOut: undefined,
}));
return result;
}
/** Find the background field ID (first image/video editable-slot with isBackground) */
function findBackgroundFieldId(template: ExpressTemplate): string | null {
for (const scene of template.scenes) {
const fields = scene.fields ?? [];
// First: look for explicit background field
const bgField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video') && f.isBackground
);
if (bgField) return bgField.id;
// Fallback: first editable media field
const mediaField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video')
);
if (mediaField) return mediaField.id;
}
return null;
}
// ─── Thumbnail component (memoized) ───
const PieceThumbnail: React.FC<{
piece: BatchPieceData;
template: ExpressTemplate;
designMD: DesignMD;
brand: CompanyProfile;
backgroundFieldId: string | null;
dimensions: { w: number; h: number };
totalFrames: number;
onClick: () => void;
isVideo: boolean;
}> = React.memo(({
piece, template, designMD, brand, backgroundFieldId,
dimensions, totalFrames, onClick, isVideo,
}) => {
const compiled = useMemo(
() => compilePiece(piece, template, designMD, brand, backgroundFieldId),
[piece, template, designMD, brand, backgroundFieldId],
);
const inputProps = useMemo(() => ({
designMD,
timelineElements: compiled.elements,
layers: compiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: template.format,
}), [designMD, compiled, template.format]);
const playerKey = useMemo(() =>
compiled.elements
.filter(el => el.type === 'video' || el.type === 'image')
.map(el => el.content || '')
.join('|'),
[compiled],
);
const hasErrors = !piece.isValid || Object.keys(piece.errors).length > 0;
const hasBackground = !!piece.backgroundUrl;
// For text-only label, get first text field value
const firstTextValue = (Object.values(piece.fieldData) as string[]).find(v => v?.trim());
return (
<button
type="button"
onClick={onClick}
title={`Pieza ${piece.index + 1}${hasErrors ? ' — datos faltantes' : ''}`}
className={`relative rounded-lg overflow-hidden border-2 transition-all hover:scale-[1.02] hover:shadow-lg hover:shadow-violet-900/20 cursor-pointer group ${
hasErrors
? 'border-amber-500/40'
: 'border-neutral-800/40 hover:border-violet-500/30'
}`}
style={{ aspectRatio: `${dimensions.w} / ${dimensions.h}` }}
>
{/* Render the piece */}
{!isVideo ? (
<Player
key={playerKey}
component={BrandComposition}
inputProps={inputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={30}
style={{ width: '100%', height: '100%', pointerEvents: 'none' }}
controls={false}
autoPlay={false}
/>
) : (
/* For video, show a static thumbnail from the background */
<div className="w-full h-full bg-neutral-900 flex items-center justify-center">
{hasBackground ? (
<video
src={piece.backgroundUrl}
muted
playsInline
className="w-full h-full object-cover"
onLoadedData={(e) => {
// Seek to 1 second for a useful thumbnail frame
(e.target as HTMLVideoElement).currentTime = 1;
}}
/>
) : (
<div className="text-neutral-700 text-[10px]">Sin fondo</div>
)}
</div>
)}
{/* Error indicator */}
{hasErrors && (
<div className="absolute top-1 right-1 bg-amber-500/90 rounded-full p-0.5">
<AlertTriangle size={10} className="text-black" />
</div>
)}
{/* Text label at bottom */}
{firstTextValue && (
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1.5">
<span className="text-[9px] text-amber-400 font-medium truncate block">
{firstTextValue}
</span>
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-violet-500/0 group-hover:bg-violet-500/10 transition-colors flex items-center justify-center">
<Eye size={16} className="text-white opacity-0 group-hover:opacity-60 transition-opacity" />
</div>
</button>
);
});
PieceThumbnail.displayName = 'PieceThumbnail';
// ─── Overflow "+N" indicator ───
const OverflowThumbnail: React.FC<{
count: number;
dimensions: { w: number; h: number };
onClick: () => void;
}> = ({ count, dimensions, onClick }) => (
<button
type="button"
onClick={onClick}
title={`Ver ${count} piezas más`}
className="relative rounded-lg overflow-hidden border-2 border-neutral-800/40 bg-neutral-900/80 flex items-center justify-center hover:border-violet-500/30 transition-all cursor-pointer"
style={{ aspectRatio: `${dimensions.w} / ${dimensions.h}` }}
>
<span className="text-lg font-bold text-neutral-400">+{count}</span>
</button>
);
// ─── Main component ───
export const BatchPreviewGrid: React.FC<BatchPreviewGridProps> = ({
pieces,
template,
brand,
designMD,
activePieceIndex,
onActivePieceChange,
}) => {
const [carouselIndex, setCarouselIndex] = useState<number | null>(null);
const carouselPlayerRef = useRef<PlayerRef>(null);
const dimensions = useMemo(() => getAspectDimensions(template.aspectRatio), [template.aspectRatio]);
const totalDuration = useMemo(() => getTemplateDuration(template), [template]);
const totalFrames = Math.max(30, totalDuration * 30);
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
const isVideo = template.format === 'video';
const N = pieces.length;
// Grid columns based on aspect ratio
const gridCols = template.aspectRatio === '16:9' ? 2
: template.aspectRatio === '1:1' ? 3
: 3; // 9:16, 4:5, etc.
// Max thumbnails to show in grid
const MAX_GRID = 8;
const visiblePieces = pieces.slice(0, MAX_GRID);
const overflowCount = Math.max(0, N - MAX_GRID);
// ─── Carousel ───
const openCarousel = useCallback((index: number) => {
setCarouselIndex(index);
}, []);
const closeCarousel = useCallback(() => {
setCarouselIndex(null);
}, []);
const navigateCarousel = useCallback((delta: number) => {
setCarouselIndex(prev => {
if (prev === null) return null;
const next = prev + delta;
if (next < 0 || next >= N) return prev;
return next;
});
}, [N]);
// Keyboard navigation for carousel
React.useEffect(() => {
if (carouselIndex === null) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeCarousel();
else if (e.key === 'ArrowLeft') navigateCarousel(-1);
else if (e.key === 'ArrowRight') navigateCarousel(1);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [carouselIndex, closeCarousel, navigateCarousel]);
// Compile carousel piece
const carouselPiece = carouselIndex !== null ? pieces[carouselIndex] : null;
const carouselCompiled = useMemo(() => {
if (!carouselPiece) return null;
return compilePiece(carouselPiece, template, designMD, brand, backgroundFieldId);
}, [carouselPiece, template, designMD, brand, backgroundFieldId]);
const carouselInputProps = useMemo(() => {
if (!carouselCompiled) return null;
return {
designMD,
timelineElements: carouselCompiled.elements,
layers: carouselCompiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: template.format,
};
}, [designMD, carouselCompiled, template.format]);
// ─── Video single preview mode ───
const videoPreviewPiece = isVideo && N > 0 ? pieces[activePieceIndex] ?? pieces[0] : null;
const videoCompiled = useMemo(() => {
if (!videoPreviewPiece) return null;
return compilePiece(videoPreviewPiece, template, designMD, brand, backgroundFieldId);
}, [videoPreviewPiece, template, designMD, brand, backgroundFieldId]);
const videoInputProps = useMemo(() => {
if (!videoCompiled) return null;
return {
designMD,
timelineElements: videoCompiled.elements,
layers: videoCompiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: template.format,
};
}, [designMD, videoCompiled, template.format]);
const videoPlayerKey = useMemo(() => {
if (!videoCompiled) return '';
return videoCompiled.elements
.filter(el => el.type === 'video' || el.type === 'image')
.map(el => el.content || '')
.join('|') + `-${activePieceIndex}`;
}, [videoCompiled, activePieceIndex]);
return (
<div className="flex-1 flex flex-col items-center justify-center relative z-10 overflow-hidden">
{/* Header */}
<div className="absolute top-4 left-5 flex items-center gap-2 z-20">
<div className={`w-2 h-2 rounded-full ${N > 0 ? 'bg-emerald-400' : 'bg-neutral-600'}`} />
<span className="text-xs font-semibold text-neutral-300">Preview del lote</span>
{N > 0 && (
<span className="text-[9px] px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400 font-mono">
{N} pieza{N !== 1 ? 's' : ''}
</span>
)}
</div>
{/* Hint */}
{N > 0 && !isVideo && (
<div className="absolute top-4 right-5 z-20">
<span className="text-[9px] text-neutral-600">
grilla · clic = grande
</span>
</div>
)}
{N === 0 ? (
/* Empty state */
<div className="text-center">
<div className="w-16 h-16 rounded-2xl bg-neutral-900 border border-neutral-800/50 flex items-center justify-center mx-auto mb-3">
<Eye size={24} className="text-neutral-700" />
</div>
<p className="text-xs text-neutral-500">Sube fondos para ver el preview</p>
<p className="text-[10px] text-neutral-600 mt-1">
Cada fondo genera una pieza con el diseño de la plantilla
</p>
</div>
) : isVideo ? (
/* ── Video mode: Single preview with piece selector ── */
<div className="flex flex-col items-center gap-3">
{/* Player */}
{videoInputProps && (
<div
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/60 border border-neutral-800/40"
style={{
width: template.aspectRatio === '9:16' ? 240
: template.aspectRatio === '1:1' ? 320
: template.aspectRatio === '4:5' ? 280
: 420,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100% - 120px)',
}}
>
<Player
key={videoPlayerKey}
component={BrandComposition}
inputProps={videoInputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={30}
style={{ width: '100%', height: '100%' }}
controls
autoPlay={false}
loop
/>
</div>
)}
{/* Piece selector */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onActivePieceChange(Math.max(0, activePieceIndex - 1))}
disabled={activePieceIndex <= 0}
title="Pieza anterior"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors disabled:opacity-30"
>
<ChevronLeft size={12} />
</button>
<span className="text-[10px] text-neutral-300 font-mono min-w-[60px] text-center">
Pieza {activePieceIndex + 1} / {N}
</span>
<button
type="button"
onClick={() => onActivePieceChange(Math.min(N - 1, activePieceIndex + 1))}
disabled={activePieceIndex >= N - 1}
title="Pieza siguiente"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors disabled:opacity-30"
>
<ChevronRight size={12} />
</button>
</div>
</div>
) : (
/* ── Image mode: Thumbnail grid ── */
<div
className="grid gap-2 p-4 max-h-[calc(100%-80px)] overflow-y-auto custom-scrollbar"
style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)`, maxWidth: 600 }}
>
{visiblePieces.map((piece) => (
<PieceThumbnail
key={piece.index}
piece={piece}
template={template}
designMD={designMD}
brand={brand}
backgroundFieldId={backgroundFieldId}
dimensions={dimensions}
totalFrames={totalFrames}
onClick={() => openCarousel(piece.index)}
isVideo={false}
/>
))}
{overflowCount > 0 && (
<OverflowThumbnail
count={overflowCount}
dimensions={dimensions}
onClick={() => openCarousel(MAX_GRID)}
/>
)}
</div>
)}
{/* Bottom hint */}
<p className="absolute bottom-4 text-[10px] text-neutral-600 z-10">
{isVideo
? 'Mismo layout y estilo en todas las piezas.'
: N > 0
? `Mismo layout y estilo en las ${N}.`
: 'Se actualiza al cargar fondos y textos'
}
</p>
{/* ═══ Fullscreen Carousel Modal ═══ */}
{carouselIndex !== null && carouselInputProps && (
<div className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center">
{/* Close */}
<button
type="button"
onClick={closeCarousel}
title="Cerrar (Esc)"
className="absolute top-4 right-4 z-50 w-10 h-10 rounded-full bg-neutral-800 hover:bg-neutral-700 text-white flex items-center justify-center transition-colors"
>
<X size={18} />
</button>
{/* Navigation */}
{carouselIndex > 0 && (
<button
type="button"
onClick={() => navigateCarousel(-1)}
title="Anterior (←)"
className="absolute left-4 z-50 w-10 h-10 rounded-full bg-neutral-800/80 hover:bg-neutral-700 text-white flex items-center justify-center transition-colors"
>
<ChevronLeft size={20} />
</button>
)}
{carouselIndex < N - 1 && (
<button
type="button"
onClick={() => navigateCarousel(1)}
title="Siguiente (→)"
className="absolute right-4 z-50 w-10 h-10 rounded-full bg-neutral-800/80 hover:bg-neutral-700 text-white flex items-center justify-center transition-colors"
>
<ChevronRight size={20} />
</button>
)}
{/* Piece counter */}
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 bg-neutral-800/80 backdrop-blur-sm px-4 py-1.5 rounded-full">
<span className="text-sm text-white font-mono">
{carouselIndex + 1} / {N}
</span>
</div>
{/* Full-size preview */}
<div
className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-700/30"
style={{
width: template.aspectRatio === '9:16' ? 380
: template.aspectRatio === '1:1' ? 500
: template.aspectRatio === '4:5' ? 440
: 640,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100vh - 100px)',
}}
>
<Player
key={`carousel-${carouselIndex}`}
ref={carouselPlayerRef}
component={BrandComposition}
inputProps={carouselInputProps}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={30}
style={{ width: '100%', height: '100%' }}
controls={isVideo}
autoPlay={false}
/>
</div>
</div>
)}
</div>
);
};
+251
View File
@@ -0,0 +1,251 @@
import React, { useState, useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import {
FolderOpen, Search, Plus, Palette, CalendarDays,
GripVertical, Briefcase, Copy, Trash2,
} from 'lucide-react';
import { CompanyProfile } from '../../types';
interface BrandsPanelProps {
companies: CompanyProfile[];
onSelect: (company: CompanyProfile) => void;
onCreateBrand: () => void;
onEditBrand: (company: CompanyProfile) => void;
onDeleteBrand: (id: string) => void;
onDuplicateBrand: (id: string) => void;
onOpenContentGrid: (companyId: string) => void;
}
/**
* BrandsPanel Top-right panel showing a searchable, draggable grid of brand folders.
*/
export const BrandsPanel: React.FC<BrandsPanelProps> = ({
companies,
onSelect,
onCreateBrand,
onEditBrand,
onDeleteBrand,
onDuplicateBrand,
onOpenContentGrid,
}) => {
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search.trim()) return companies;
const q = search.toLowerCase();
return companies.filter(c =>
c.name.toLowerCase().includes(q) ||
(c.industry || '').toLowerCase().includes(q)
);
}, [companies, search]);
return (
<div className="flex-1 min-w-0 bg-neutral-900/50 border border-neutral-800/50 rounded-2xl overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="text-amber-400" />
<h2 className="text-sm font-bold text-white">Marcas</h2>
<span className="text-[10px] bg-neutral-800 text-neutral-400 px-1.5 py-0.5 rounded-full font-mono">
{companies.length}
</span>
</div>
<button
onClick={onCreateBrand}
title="Crear nueva marca"
className="flex items-center gap-1 text-[10px] text-amber-400 hover:text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 hover:border-amber-500/40 px-2 py-1 rounded-lg font-semibold transition-all"
>
<Plus size={11} /> Nueva
</button>
</div>
{/* Search */}
<div className="px-4 pb-3">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar marca..."
className="w-full bg-neutral-800/60 border border-neutral-700/50 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-amber-500/40 transition-colors"
/>
</div>
</div>
{/* Grid */}
<div className="flex-1 overflow-y-auto px-4 pb-4 custom-scrollbar">
<div className="grid grid-cols-2 gap-2">
{filtered.map(company => (
<DraggableBrand
key={company.id}
company={company}
onSelect={onSelect}
onEditBrand={onEditBrand}
onDeleteBrand={onDeleteBrand}
onDuplicateBrand={onDuplicateBrand}
onOpenContentGrid={onOpenContentGrid}
/>
))}
</div>
{companies.length === 0 && (
<div className="text-center py-8 text-neutral-600">
<Briefcase size={24} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">No hay marcas creadas</p>
<p className="text-[10px] mt-1 text-neutral-700">Haz clic en "Nueva" para empezar</p>
</div>
)}
{filtered.length === 0 && search.trim() && companies.length > 0 && (
<div className="text-center py-6 text-neutral-600">
<Search size={20} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">Sin resultados para "{search}"</p>
</div>
)}
</div>
</div>
);
};
/* ── Draggable brand folder ── */
const DraggableBrand: React.FC<{
company: CompanyProfile;
onSelect: (c: CompanyProfile) => void;
onEditBrand: (c: CompanyProfile) => void;
onDeleteBrand: (id: string) => void;
onDuplicateBrand: (id: string) => void;
onOpenContentGrid: (id: string) => void;
}> = ({ company, onSelect, onEditBrand, onDeleteBrand, onDuplicateBrand, onOpenContentGrid }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: `brand-${company.id}`,
data: { type: 'brand', company },
});
const style = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onClick={() => onSelect(company)}
title={`${company.name}${company.industry ? ` · ${company.industry}` : ''}`}
className={`
group relative rounded-xl border p-3 cursor-grab active:cursor-grabbing
transition-all duration-150
${isDragging
? 'border-amber-500/60 shadow-xl shadow-amber-900/30 z-50 bg-neutral-900'
: 'border-neutral-800/60 bg-neutral-950/30 hover:border-amber-500/30 hover:bg-neutral-900/60'
}
`}
>
{/* Drag grip hint */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-60 transition-opacity">
<GripVertical size={10} className="text-neutral-500" />
</div>
{/* Brand icon (folder) */}
<div className="flex items-center gap-2.5 mb-2">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center p-1 border shrink-0"
style={{
backgroundColor: company.design.secondaryColor,
borderColor: `${company.design.primaryColor}40`,
}}
>
{company.design.logoUrl ? (
<img src={company.design.logoUrl} className="max-w-full max-h-full object-contain" alt="" />
) : (
<FolderOpen size={14} className="text-amber-400" />
)}
</div>
<p className="text-xs font-bold text-white truncate group-hover:text-amber-300 transition-colors">
{company.name}
</p>
</div>
{/* Color dots */}
<div className="flex items-center gap-3">
<div className="flex gap-1">
<div className="w-3 h-3 rounded-full border border-neutral-700" style={{ backgroundColor: company.design.primaryColor }} title="Primario" />
<div className="w-3 h-3 rounded-full border border-neutral-700" style={{ backgroundColor: company.design.secondaryColor }} title="Secundario" />
<div className="w-3 h-3 rounded-full border border-neutral-700" style={{ backgroundColor: company.design.textColor }} title="Texto" />
</div>
</div>
{/* Hover actions */}
<div className="absolute bottom-1.5 right-1.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); onEditBrand(company); }}
title="Editar Design Kit"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Palette size={9} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onOpenContentGrid(company.id); }}
title="Malla de Contenidos"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-fuchsia-400 rounded transition-colors"
>
<CalendarDays size={9} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDuplicateBrand(company.id); }}
title="Duplicar marca"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Copy size={9} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeleteBrand(company.id); }}
title="Eliminar marca"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-red-400 rounded transition-colors"
>
<Trash2 size={9} />
</button>
</div>
</div>
);
};
/**
* DragOverlay content for a brand being dragged.
*/
export const BrandDragPreview: React.FC<{ company: CompanyProfile }> = ({ company }) => (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-neutral-800/95 border border-amber-500/50 shadow-2xl shadow-amber-900/40 backdrop-blur-sm">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center p-1 border"
style={{
backgroundColor: company.design.secondaryColor,
borderColor: `${company.design.primaryColor}40`,
}}
>
{company.design.logoUrl ? (
<img src={company.design.logoUrl} className="max-w-full max-h-full object-contain" alt="" />
) : (
<FolderOpen size={14} className="text-amber-400" />
)}
</div>
<div>
<p className="text-xs font-bold text-white">{company.name}</p>
<div className="flex gap-1 mt-0.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: company.design.primaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: company.design.secondaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: company.design.textColor }} />
</div>
</div>
</div>
);
+150
View File
@@ -0,0 +1,150 @@
import React from 'react';
import { useDroppable } from '@dnd-kit/core';
import {
Layers, FolderOpen, X,
Video, Image as ImageIcon,
} from 'lucide-react';
import { ExpressTemplate, CompanyProfile } from '../../types';
interface DropSlotProps {
type: 'template' | 'brand';
item: ExpressTemplate | CompanyProfile | null;
onClear: () => void;
onClick: () => void;
}
/**
* DropSlot A single droppable slot for either a template or brand.
*
* States:
* - Empty: dashed border, placeholder icon + text
* - Drag hover: highlighted border, glowing background
* - Filled: shows selected item with remove button
*/
export const DropSlot: React.FC<DropSlotProps> = ({
type,
item,
onClear,
onClick,
}) => {
const { isOver, setNodeRef } = useDroppable({
id: `slot-${type}`,
data: { accepts: type },
});
const isEmpty = !item;
const label = type === 'template' ? 'Plantilla' : 'Marca';
const hint = type === 'template' ? 'Suelta una plantilla' : 'Suelta una marca';
return (
<div
ref={setNodeRef}
onClick={isEmpty ? onClick : undefined}
className={`
relative flex items-center gap-3 px-4 py-4 rounded-xl border-2 transition-all duration-200 min-w-[180px] cursor-pointer
${isEmpty && !isOver
? 'border-dashed border-neutral-700/60 bg-neutral-900/30 hover:border-neutral-600 hover:bg-neutral-900/50'
: ''
}
${isEmpty && isOver
? 'border-dashed border-violet-500/70 bg-violet-950/30 scale-[1.02] shadow-lg shadow-violet-900/20'
: ''
}
${!isEmpty
? 'border-solid border-violet-500/40 bg-neutral-900/60'
: ''
}
`}
title={isEmpty ? `Haz clic o arrastra para elegir ${label.toLowerCase()}` : `${label} seleccionada`}
>
{isEmpty ? (
/* ── Empty state ── */
<div className="flex items-center gap-3 py-1">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
isOver ? 'bg-violet-600/20 text-violet-400' : 'bg-neutral-800/60 text-neutral-600'
}`}>
{type === 'template' ? <Layers size={20} /> : <FolderOpen size={20} />}
</div>
<div>
<p className={`text-[10px] font-semibold uppercase tracking-wider transition-colors ${
isOver ? 'text-violet-400' : 'text-neutral-500'
}`}>
{label}
</p>
<p className={`text-xs transition-colors ${
isOver ? 'text-violet-300/70' : 'text-neutral-600'
}`}>
{hint}
</p>
</div>
</div>
) : type === 'template' ? (
/* ── Filled: Template ── */
<FilledTemplate template={item as ExpressTemplate} />
) : (
/* ── Filled: Brand ── */
<FilledBrand brand={item as CompanyProfile} />
)}
{/* Clear button */}
{!isEmpty && (
<button
onClick={(e) => { e.stopPropagation(); onClear(); }}
title={`Quitar ${label.toLowerCase()}`}
className="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-neutral-800 border border-neutral-700 flex items-center justify-center text-neutral-400 hover:text-red-400 hover:border-red-500/40 transition-colors shadow-sm"
>
<X size={10} />
</button>
)}
</div>
);
};
/* ── Sub-components for filled state ── */
const FilledTemplate: React.FC<{ template: ExpressTemplate }> = ({ template }) => (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-violet-600/15 flex items-center justify-center text-lg">
{template.icon}
</div>
<div>
<p className="text-[10px] text-violet-400 font-semibold uppercase tracking-wider">Plantilla</p>
<p className="text-xs font-bold text-white">{template.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
{template.format === 'video' ? (
<Video size={9} className="text-violet-400" />
) : (
<ImageIcon size={9} className="text-sky-400" />
)}
<span className="text-[8px] text-neutral-500 font-mono">{template.aspectRatio}</span>
</div>
</div>
</div>
);
const FilledBrand: React.FC<{ brand: CompanyProfile }> = ({ brand }) => (
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center p-1.5 border"
style={{
backgroundColor: brand.design.secondaryColor,
borderColor: `${brand.design.primaryColor}40`,
}}
>
{brand.design.logoUrl ? (
<img src={brand.design.logoUrl} className="max-w-full max-h-full object-contain" alt="Logo" />
) : (
<FolderOpen size={16} className="text-neutral-400" />
)}
</div>
<div>
<p className="text-[10px] text-amber-400 font-semibold uppercase tracking-wider">Marca</p>
<p className="text-xs font-bold text-white">{brand.name}</p>
<div className="flex gap-1 mt-0.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: brand.design.primaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: brand.design.secondaryColor }} />
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: brand.design.textColor }} />
</div>
</div>
</div>
);
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import { Sparkles, ArrowRight } from 'lucide-react';
import { ExpressTemplate, CompanyProfile } from '../../types';
import { DropSlot } from './DropSlot';
interface GenerateZoneProps {
selectedTemplate: ExpressTemplate | null;
selectedBrand: CompanyProfile | null;
onClearTemplate: () => void;
onClearBrand: () => void;
onClickTemplateSlot: () => void;
onClickBrandSlot: () => void;
onGenerate: () => void;
}
/**
* GenerateZone Bottom full-width area with two drop slots (Template × Brand) and a Generate button.
*/
export const GenerateZone: React.FC<GenerateZoneProps> = ({
selectedTemplate,
selectedBrand,
onClearTemplate,
onClearBrand,
onClickTemplateSlot,
onClickBrandSlot,
onGenerate,
}) => {
const canGenerate = !!selectedTemplate && !!selectedBrand;
return (
<div className="bg-neutral-900/50 border border-neutral-800/50 rounded-2xl p-5">
{/* Header */}
<div className="flex items-center gap-2 mb-1">
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-violet-600/20 to-fuchsia-600/20 flex items-center justify-center">
<Sparkles size={14} className="text-violet-400" />
</div>
<h2 className="text-sm font-bold text-white">Generar contenido</h2>
</div>
<p className="text-[11px] text-neutral-500 mb-5 ml-8">
Arrastra una plantilla y una marca, o toca para elegir.
</p>
{/* Slots row */}
<div className="flex items-center gap-3">
{/* Template slot */}
<DropSlot
type="template"
item={selectedTemplate}
onClear={onClearTemplate}
onClick={onClickTemplateSlot}
/>
{/* × separator */}
<div className="shrink-0 flex items-center justify-center">
<span className="text-xl font-bold text-neutral-600 select-none">×</span>
</div>
{/* Brand slot */}
<DropSlot
type="brand"
item={selectedBrand}
onClear={onClearBrand}
onClick={onClickBrandSlot}
/>
{/* Generate button */}
<button
onClick={onGenerate}
disabled={!canGenerate}
title={canGenerate ? 'Generar contenido con esta plantilla y marca' : 'Selecciona una plantilla y una marca primero'}
className={`
shrink-0 flex items-center gap-2 px-6 py-4 rounded-xl font-bold text-sm transition-all duration-200
${canGenerate
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-lg shadow-violet-900/30 hover:shadow-violet-900/50 hover:scale-[1.02] active:scale-[0.98]'
: 'bg-neutral-800/50 text-neutral-600 cursor-not-allowed border border-neutral-800'
}
`}
>
Generar
<ArrowRight size={16} />
</button>
</div>
</div>
);
};
+658
View File
@@ -0,0 +1,658 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import {
ArrowLeft, Sparkles, Zap, Play, ChevronRight, ChevronLeft, FileText, Download, Film,
Layers, Package,
} from 'lucide-react';
import { PlayerRef } from '@remotion/player';
import {
ExpressTemplate, CompanyProfile, DesignMD,
TemplateField, BrandSource,
} from '../../types';
import { ExportModal } from '../export/ExportModal';
import { compileExpressToTimeline, getTemplateDuration } from '../../utils/expressCompiler';
import { TemplateFieldInput } from '../shared/TemplateFieldInput';
import { LivePreviewCanvas } from '../shared/LivePreviewCanvas';
import { migrateExpressFields } from '../../context/TemplateBuilderContext';
import { useBatchProduction } from '../../hooks/useBatchProduction';
import { BatchDataPanel } from './BatchDataPanel';
import { exportBatchAsZip, BatchExportProgress } from '../../utils/batchExporter';
interface ProductionFormProps {
template: ExpressTemplate;
brand: CompanyProfile;
onBack: () => void;
onProducePro: (fieldData: Record<string, string>) => void;
}
/** Resolve a brand variable to its value from DesignMD / CompanyProfile */
function resolveBrandValue(source: BrandSource | undefined, brand: CompanyProfile): string {
if (!source) return '';
switch (source) {
case 'brand-name': return brand.name || brand.design.brandName || '';
case 'tagline': return brand.tagline || '';
case 'logo': return brand.design.logoUrl || '';
case 'intro-video': return brand.design.introVideoUrl || '';
case 'outro-video': return brand.design.outroVideoUrl || '';
case 'primary-color': return brand.design.primaryColor;
case 'secondary-color': return brand.design.secondaryColor;
case 'instagram': return brand.socialLinks?.instagram || '';
case 'tiktok': return brand.socialLinks?.tiktok || '';
case 'twitter': return brand.socialLinks?.x || '';
case 'youtube': return brand.socialLinks?.youtube || '';
case 'website': return brand.socialLinks?.website || '';
default: return '';
}
}
/** Get all TemplateFields from a scene, migrating legacy fields if needed */
function getSceneTemplateFields(scene: ExpressTemplate['scenes'][0]): TemplateField[] {
if (scene.fields && scene.fields.length > 0) return scene.fields;
return migrateExpressFields(scene.editableFields);
}
/** Find the background field ID (first image/video editable-slot, prefer isBackground) */
function findBackgroundFieldId(template: ExpressTemplate): string | null {
for (const scene of template.scenes) {
const fields = scene.fields ?? [];
const bgField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video') && f.isBackground
);
if (bgField) return bgField.id;
const mediaField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video')
);
if (mediaField) return mediaField.id;
}
return null;
}
/**
* ProductionForm Two-panel production screen.
*
* Supports two modes:
* - Single piece (default): editable fields form + live preview
* - Batch mode: multi-file upload + text table + thumbnail grid preview
*
* Uses shared TemplateFieldInput and LivePreviewCanvas components.
*/
export const ProductionForm: React.FC<ProductionFormProps> = ({
template,
brand,
onBack,
onProducePro,
}) => {
const [fieldData, setFieldData] = useState<Record<string, string>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [activeSceneId, setActiveSceneId] = useState<string | null>(
template.scenes[0]?.id || null
);
const [showExportModal, setShowExportModal] = useState(false);
const [mediaFits, setMediaFits] = useState<Record<string, 'cover' | 'contain' | 'fill'>>({});
const [containBgColors, setContainBgColors] = useState<Record<string, string | null>>({});
// Batch mode state
const [batchExportProgress, setBatchExportProgress] = useState<BatchExportProgress | null>(null);
const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0);
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
const playerRef = useRef<PlayerRef>(null);
const designMD = brand.design;
const fps = 30;
const totalDuration = getTemplateDuration(template);
const totalFrames = Math.max(30, totalDuration * fps);
// ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ───
const compiled = useMemo(
() => {
if (!showExportModal) return { elements: [], layers: [] };
const result = compileExpressToTimeline(template, fieldData, designMD, brand);
result.elements = result.elements.map(el => {
const fieldId = el.sourceFieldId;
const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
const bgOverride = fieldId ? containBgColors[fieldId] : undefined;
return {
...el,
transitionIn: undefined,
transitionOut: undefined,
...(fitOverride ? { objectFit: fitOverride } : {}),
...(bgOverride !== undefined ? { containBgColor: bgOverride } : {}),
};
});
return result;
},
[showExportModal, template, fieldData, designMD, brand, mediaFits, containBgColors]
);
// ─── Collect all TemplateFields across all scenes ───
const allFields = useMemo(() => {
const fields: { field: TemplateField; sceneId: string; sceneName: string }[] = [];
for (const scene of template.scenes) {
const sceneFields = getSceneTemplateFields(scene);
for (const f of sceneFields) {
fields.push({ field: f, sceneId: scene.id, sceneName: scene.name });
}
}
return fields;
}, [template]);
// Separate into editable slots and brand variables
const editableSlots = useMemo(() =>
allFields
.filter(f => f.field.nature === 'editable-slot')
.sort((a, b) => a.field.formOrder - b.field.formOrder),
[allFields]);
const brandVars = useMemo(() =>
allFields.filter(f => f.field.nature === 'brand-variable'),
[allFields]);
// Group editable slots by scene
const sceneGroups = useMemo(() => {
const groups: { sceneId: string; sceneName: string; fields: typeof editableSlots }[] = [];
const seen = new Set<string>();
for (const slot of editableSlots) {
if (!seen.has(slot.sceneId)) {
seen.add(slot.sceneId);
groups.push({
sceneId: slot.sceneId,
sceneName: slot.sceneName,
fields: editableSlots.filter(s => s.sceneId === slot.sceneId),
});
}
}
return groups;
}, [editableSlots]);
const isMultiScene = sceneGroups.length > 1;
// ─── Batch mode hook ───
const batch = useBatchProduction(editableSlots, template.format);
// Active preview fieldData: in batch mode, use the active piece's data with background injected
const activePreviewFieldData = useMemo(() => {
if (!batch.isBatchMode) return fieldData;
const piece = batch.pieces[activeBatchPieceIndex];
if (!piece) return {};
const fd: Record<string, string> = { ...piece.fieldData };
if (backgroundFieldId && piece.backgroundUrl) {
fd[backgroundFieldId] = piece.backgroundUrl;
}
return fd;
}, [batch.isBatchMode, batch.pieces, activeBatchPieceIndex, backgroundFieldId, fieldData]);
const handleChange = useCallback((fieldId: string, value: string) => {
setFieldData(prev => ({ ...prev, [fieldId]: value }));
setErrors(prev => { const next = { ...prev }; delete next[fieldId]; return next; });
}, []);
const validate = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
for (const { field } of editableSlots) {
const value = fieldData[field.id]?.trim();
if (field.required && !value) {
newErrors[field.id] = 'Campo obligatorio';
}
if (field.type === 'text' && field.rules?.maxChars && value && value.length > field.rules.maxChars) {
newErrors[field.id] = `Máximo ${field.rules.maxChars} caracteres`;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [editableSlots, fieldData]);
// ─── Detect form-sourced segments (intro/outro that need video upload) ───
const formSegments = useMemo(() =>
template.scenes.filter(
s => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'form'
),
[template]);
// ─── Detect brand-sourced segments (auto intro/outro from brand) ───
const brandSegments = useMemo(() =>
template.scenes.filter(
s => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'brand'
),
[template]);
// ─── Required fields completion check (includes segments) ───
const requiredComplete = useMemo(() => {
const slotsComplete = editableSlots
.filter(s => s.field.required)
.every(s => !!fieldData[s.field.id]?.trim());
const segmentsComplete = formSegments
.filter(s => s.segmentFieldRequired !== false)
.every(s => !!fieldData[`segment-${s.id}`]?.trim());
return slotsComplete && segmentsComplete;
}, [editableSlots, fieldData, formSegments]);
const handleProducePro = () => { if (validate()) onProducePro(fieldData); };
const handleProduce = () => { if (validate()) setShowExportModal(true); };
// ─── Batch export handler ───
const handleBatchExport = useCallback(async () => {
if (!batch.validateAll()) return;
setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' });
try {
await exportBatchAsZip(
batch.pieces,
template,
brand,
{ format: 'png' },
(progress) => setBatchExportProgress(progress),
);
} catch (err) {
console.error('Batch export failed:', err);
setBatchExportProgress({ current: 0, total: 0, status: 'error', error: String(err) });
}
}, [batch, template, brand]);
return (
<div className="flex-1 flex overflow-hidden bg-neutral-950 relative">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
/>
{/* ═══ LEFT PANEL ═══ */}
<div className="w-[440px] shrink-0 flex flex-col border-r border-neutral-800/60 relative z-10 bg-neutral-950/95 backdrop-blur-sm">
{/* Top bar */}
<div className="px-5 py-3 border-b border-neutral-800/50 shrink-0">
<button
onClick={onBack}
title="Volver al dashboard"
className="flex items-center gap-1.5 text-neutral-400 hover:text-white transition-colors text-xs mb-3"
>
<ArrowLeft size={14} />
Dashboard
</button>
<div className="flex items-center gap-2 mb-1.5">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-md shadow-violet-900/30">
<Sparkles size={16} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-bold text-white tracking-tight truncate">Producir Contenido</h1>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-violet-400 font-semibold truncate">{template.icon} {template.name}</span>
<span className="text-neutral-600 text-[10px]">×</span>
<span className="text-[10px] text-amber-400 font-semibold truncate">{brand.name}</span>
</div>
</div>
{/* ── Batch Toggle ── */}
<button
type="button"
onClick={batch.toggleBatchMode}
title={batch.isBatchMode ? 'Cambiar a pieza única' : 'Cambiar a modo lote'}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-[10px] font-semibold transition-all shrink-0 ${
batch.isBatchMode
? 'bg-violet-600/20 border-violet-500/40 text-violet-300 shadow-sm shadow-violet-900/20'
: 'bg-neutral-900 border-neutral-700 text-neutral-400 hover:text-white hover:border-neutral-600'
}`}
>
<div className={`w-7 h-4 rounded-full relative transition-colors ${
batch.isBatchMode ? 'bg-violet-600' : 'bg-neutral-700'
}`}>
<div className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow-sm transition-all ${
batch.isBatchMode ? 'left-3.5' : 'left-0.5'
}`} />
</div>
<Layers size={11} />
En lote
</button>
</div>
<div className="flex items-center gap-2">
<span className={`text-[9px] px-2 py-0.5 rounded-full font-bold ${
template.format === 'video' ? 'bg-violet-500/15 text-violet-300' : 'bg-sky-500/15 text-sky-300'
}`}>
{template.format === 'video' ? '🎬 Video' : '🖼️ Imagen'} · {template.aspectRatio}
</span>
<span className="text-[9px] px-2 py-0.5 rounded-full bg-neutral-800 text-neutral-400 font-mono">
{template.scenes.length} escena{template.scenes.length !== 1 ? 's' : ''}
</span>
</div>
</div>
{/* ═══ CONDITIONAL CONTENT: Single vs Batch ═══ */}
{batch.isBatchMode ? (
/* ── BATCH MODE: BatchDataPanel ── */
<BatchDataPanel
pieces={batch.pieces}
editableSlots={editableSlots}
brand={brand}
templateFormat={template.format}
onSetBackgrounds={batch.setBackgroundFiles}
onUpdateField={batch.updatePieceField}
onImportCSV={batch.importCSV}
onRemovePiece={batch.removePiece}
backgroundFiles={batch.backgroundFiles}
/>
) : (
/* ── SINGLE MODE: Original form ── */
<>
{/* Form header */}
<div className="px-5 py-3 border-b border-neutral-800/30 bg-gradient-to-r from-violet-500/5 to-fuchsia-500/5 shrink-0">
<div className="flex items-center gap-2">
<FileText size={13} className="text-violet-400" />
<h2 className="text-xs font-bold text-white">Campos editables</h2>
<span className="text-[9px] text-violet-400 bg-violet-500/10 px-2 py-0.5 rounded-full font-medium">
{editableSlots.length} campo{editableSlots.length !== 1 ? 's' : ''}
</span>
</div>
<p className="text-[10px] text-neutral-500 mt-1">
El estilo de <span className="text-amber-400">{brand.name}</span> se aplica automáticamente.
</p>
</div>
{/* Scrollable fields */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
{/* ── Segment upload fields (form-sourced intro/outro) ── */}
{formSegments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 px-1">
<Film size={12} className="text-emerald-400" />
<span className="text-[10px] font-bold text-white uppercase tracking-wider">Segmentos de video</span>
</div>
{formSegments.map(scene => {
const segFieldId = `segment-${scene.id}`;
const isIntro = scene.type === 'intro';
const syntheticField: TemplateField = {
id: segFieldId,
nature: 'editable-slot',
type: 'video',
label: scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre'),
required: scene.segmentFieldRequired ?? true,
content: isIntro ? 'Video de intro' : 'Video de cierre',
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: isIntro ? -2 : 999,
};
return (
<TemplateFieldInput
key={segFieldId}
field={syntheticField}
value={fieldData[segFieldId] || ''}
onChange={(v) => handleChange(segFieldId, v)}
error={errors[segFieldId]}
designMD={designMD}
mediaFit={mediaFits[segFieldId]}
onMediaFitChange={(fit) => setMediaFits(prev => ({ ...prev, [segFieldId]: fit }))}
containBgColor={containBgColors[segFieldId] ?? null}
onContainBgColorChange={(color) => setContainBgColors(prev => ({ ...prev, [segFieldId]: color }))}
/>
);
})}
</div>
)}
{editableSlots.length === 0 ? (
<div className="text-center py-8">
<FileText size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">Esta plantilla no tiene campos editables.</p>
<p className="text-[10px] text-neutral-600 mt-1">Todo se genera automáticamente desde la marca.</p>
</div>
) : isMultiScene ? (
/* ── Grouped by scene ── */
sceneGroups.map(group => (
<div key={group.sceneId} className="space-y-3">
<button
onClick={() => setActiveSceneId(group.sceneId)}
title={`Ir a escena: ${group.sceneName}`}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-all ${
activeSceneId === group.sceneId
? 'border-violet-500/30 bg-violet-500/5'
: 'border-neutral-800/50 bg-neutral-900/30 hover:border-neutral-700'
}`}
>
<div className={`w-2 h-2 rounded-full shrink-0 ${
activeSceneId === group.sceneId ? 'bg-violet-500' : 'bg-neutral-600'
}`} />
<span className="text-[11px] font-semibold text-white flex-1">{group.sceneName}</span>
<span className="text-[9px] text-neutral-500">{group.fields.length} campo{group.fields.length !== 1 ? 's' : ''}</span>
</button>
{group.fields.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={fieldData[field.id] || ''}
onChange={(v) => handleChange(field.id, v)}
error={errors[field.id]}
designMD={designMD}
mediaFit={mediaFits[field.id]}
onMediaFitChange={(fit) => setMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={containBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))}
</div>
))
) : (
/* ── Single scene — flat list ── */
editableSlots.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={fieldData[field.id] || ''}
onChange={(v) => handleChange(field.id, v)}
error={errors[field.id]}
designMD={designMD}
mediaFit={mediaFits[field.id]}
onMediaFitChange={(fit) => setMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={containBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))
)}
{/* Brand-sourced segments (auto intro/outro) */}
{brandSegments.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-emerald-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Sparkles size={8} /> Segmentos automáticos
</p>
<div className="space-y-2">
{brandSegments.map(scene => (
<div
key={scene.id}
className="flex items-center gap-3 px-3 py-2.5 bg-emerald-500/5 border border-emerald-500/15 rounded-lg"
>
<Film size={10} className="text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-emerald-300 font-medium">{scene.name}</span>
<span className="text-[9px] text-emerald-400/50 block">
{scene.durationSeconds}s desde la marca
</span>
</div>
<span className="text-[7px] text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
{/* Brand variables (read-only info) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde {brand.name}
</p>
<div className="space-y-2">
{brandVars.map(({ field }) => {
const resolvedValue = resolveBrandValue(field.brandSource, brand);
return (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{field.brandSource === 'logo' ? '(Logo de marca)' : resolvedValue || '(no configurado)'}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
);
})}
</div>
</div>
)}
</div>
</>
)}
{/* ── Sticky footer with actions ── */}
<div className="px-5 py-3 border-t border-neutral-800/60 bg-neutral-950/95 backdrop-blur-sm shrink-0">
<div className="flex items-center gap-2">
<button
onClick={onBack}
title="Cancelar y volver al dashboard"
className="px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-white hover:border-neutral-700 text-[10px] font-medium transition-all"
>
Cancelar
</button>
<div className="flex-1" />
{batch.isBatchMode ? (
/* ── Batch footer ── */
<>
{/* Export progress indicator */}
{batchExportProgress && batchExportProgress.status === 'rendering' && (
<div className="flex items-center gap-2 mr-2">
<div className="w-20 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-violet-600 to-fuchsia-600 rounded-full transition-all"
style={{ width: `${(batchExportProgress.current / batchExportProgress.total) * 100}%` }}
/>
</div>
<span className="text-[9px] text-neutral-400 font-mono">
{batchExportProgress.current}/{batchExportProgress.total}
</span>
</div>
)}
<button
onClick={handleBatchExport}
disabled={batch.pieceCount === 0 || (batchExportProgress?.status === 'rendering')}
title={batch.pieceCount === 0 ? 'Sube fondos para comenzar' : `Generar y descargar ${batch.pieceCount} piezas como ZIP`}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-[10px] font-bold transition-all shadow-lg hover:scale-[1.01] active:scale-[0.99] ${
batch.pieceCount > 0 && batchExportProgress?.status !== 'rendering'
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-violet-900/30 hover:shadow-violet-900/50'
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed shadow-none'
}`}
>
<Package size={12} />
Descargar ZIP ({batch.pieceCount})
<ChevronRight size={10} />
</button>
</>
) : (
/* ── Single piece footer ── */
<>
<button
onClick={handleProducePro}
title="Abrir en Editor Pro con timeline completo"
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-white text-[10px] font-semibold transition-all hover:scale-[1.01] active:scale-[0.99]"
>
<Play size={11} />
Editor Pro
<ChevronRight size={10} />
</button>
<button
onClick={handleProduce}
disabled={!requiredComplete}
title={requiredComplete ? 'Producir contenido' : 'Completa los campos obligatorios'}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-[10px] font-bold transition-all shadow-lg hover:scale-[1.01] active:scale-[0.99] ${
requiredComplete
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-violet-900/30 hover:shadow-violet-900/50'
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed shadow-none'
}`}
>
<Download size={12} />
Producir
<ChevronRight size={10} />
</button>
</>
)}
</div>
</div>
</div>
{/* ═══ RIGHT PANEL — Same LivePreviewCanvas for both modes ═══ */}
<div className="flex-1 flex flex-col relative z-10">
<LivePreviewCanvas
template={template}
fieldData={activePreviewFieldData}
brand={brand}
designMD={designMD}
mediaFits={mediaFits}
containBgColors={containBgColors}
activeSceneId={activeSceneId}
onSceneChange={setActiveSceneId}
playerRef={playerRef}
statusLabel={
batch.isBatchMode
? (batch.pieceCount > 0 ? `Pieza ${activeBatchPieceIndex + 1} de ${batch.pieceCount}` : 'Sin piezas')
: (requiredComplete ? 'Listo' : 'Faltan campos')
}
isComplete={
batch.isBatchMode
? (batch.pieceCount > 0 && (batch.pieces[activeBatchPieceIndex]?.isValid ?? false))
: requiredComplete
}
/>
{/* ── Piece Navigator (batch mode only) ── */}
{batch.isBatchMode && batch.pieceCount > 0 && (
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-neutral-900/90 backdrop-blur-sm border border-neutral-700/50 rounded-full px-4 py-2 shadow-xl">
<button
type="button"
onClick={() => setActiveBatchPieceIndex(Math.max(0, activeBatchPieceIndex - 1))}
disabled={activeBatchPieceIndex <= 0}
title="Pieza anterior"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft size={14} />
</button>
<span className="text-[11px] text-neutral-300 font-semibold min-w-[80px] text-center">
Pieza {activeBatchPieceIndex + 1} / {batch.pieceCount}
</span>
<button
type="button"
onClick={() => setActiveBatchPieceIndex(Math.min(batch.pieceCount - 1, activeBatchPieceIndex + 1))}
disabled={activeBatchPieceIndex >= batch.pieceCount - 1}
title="Pieza siguiente"
className="w-7 h-7 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight size={14} />
</button>
</div>
)}
</div>
{/* ═══ Export Modal (single piece only) ═══ */}
<ExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
designMD={designMD}
textOverlay=""
timelineElements={compiled.elements}
layers={compiled.layers}
durationInFrames={totalFrames}
brandVisibility={{ logo: false, frame: false, background: true }}
outputFormat={template.format}
aspectRatio={template.aspectRatio}
/>
</div>
);
};
+509
View File
@@ -0,0 +1,509 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import {
Layers, Search, Video, Image as ImageIcon, Plus, ArrowRight,
GripVertical, Pencil, Copy, Trash2, Smartphone, Monitor, Square,
Star, Package, ChevronDown, ChevronRight,
} from 'lucide-react';
import { ExpressTemplate } from '../../types';
const ASPECTS: { value: ExpressTemplate['aspectRatio']; label: string; icon: React.ReactNode; desc: string }[] = [
{ value: '9:16', label: '9:16', icon: <Smartphone size={14} />, desc: 'Stories · Reels' },
{ value: '16:9', label: '16:9', icon: <Monitor size={14} />, desc: 'YouTube · Web' },
{ value: '1:1', label: '1:1', icon: <Square size={14} />, desc: 'Feed · Posts' },
{ value: '4:5', label: '4:5', icon: <Smartphone size={14} />, desc: 'IG vertical' },
];
interface TemplatesPanelProps {
templates: ExpressTemplate[];
onSelect: (template: ExpressTemplate) => void;
onCreateTemplate: (format: 'video' | 'image', aspect: ExpressTemplate['aspectRatio']) => void;
onEditTemplate: (template: ExpressTemplate) => void;
onDuplicateTemplate: (template: ExpressTemplate) => void;
onDeleteTemplate: (id: string) => void;
}
/**
* TemplatesPanel Top-left panel showing a searchable, draggable grid of templates.
*/
export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({
templates,
onSelect,
onCreateTemplate,
onEditTemplate,
onDuplicateTemplate,
onDeleteTemplate,
}) => {
const [search, setSearch] = useState('');
const [popoverOpen, setPopoverOpen] = useState(false);
const [customOpen, setCustomOpen] = useState(true);
const [presetOpen, setPresetOpen] = useState(true);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
if (!search.trim()) return templates;
const q = search.toLowerCase();
return templates.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.category.toLowerCase().includes(q)
);
}, [templates, search]);
const customTemplates = useMemo(() => filtered.filter(t => t.isCustom === true), [filtered]);
const presetTemplates = useMemo(() => filtered.filter(t => !t.isCustom), [filtered]);
// ── Close popover on click outside ──
useEffect(() => {
if (!popoverOpen) return;
const handleMouseDown = (e: MouseEvent) => {
if (
popoverRef.current && !popoverRef.current.contains(e.target as Node) &&
buttonRef.current && !buttonRef.current.contains(e.target as Node)
) {
setPopoverOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
}, [popoverOpen]);
// ── Close popover on Escape ──
useEffect(() => {
if (!popoverOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setPopoverOpen(false);
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [popoverOpen]);
// ── Auto-focus first interactive element when popover opens ──
useEffect(() => {
if (popoverOpen && popoverRef.current) {
const first = popoverRef.current.querySelector<HTMLElement>('button, [tabindex]');
first?.focus();
}
}, [popoverOpen]);
return (
<div className="flex-1 min-w-0 bg-neutral-900/50 border border-neutral-800/50 rounded-2xl overflow-hidden flex flex-col relative">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2">
<div className="flex items-center gap-2">
<Layers size={16} className="text-violet-400" />
<h2 className="text-sm font-bold text-white">Plantillas</h2>
<span className="text-[10px] bg-neutral-800 text-neutral-400 px-1.5 py-0.5 rounded-full font-mono">
{templates.length}
</span>
</div>
<button
ref={buttonRef}
onClick={() => setPopoverOpen(prev => !prev)}
title="Crear nueva plantilla"
aria-expanded={popoverOpen}
aria-haspopup="dialog"
className={`flex items-center gap-1 text-[10px] font-semibold px-2 py-1 rounded-lg transition-all ${
popoverOpen
? 'text-violet-300 bg-violet-500/20 border border-violet-500/40'
: 'text-violet-400 hover:text-violet-300 bg-violet-500/10 hover:bg-violet-500/20 border border-violet-500/20 hover:border-violet-500/40'
}`}
>
<Plus size={11} /> Nueva
</button>
</div>
{/* Creation Popover */}
{popoverOpen && (
<CreateTemplatePopover
ref={popoverRef}
onCreateTemplate={(format, aspect) => {
onCreateTemplate(format, aspect);
setPopoverOpen(false);
}}
onClose={() => {
setPopoverOpen(false);
buttonRef.current?.focus();
}}
/>
)}
{/* Search */}
<div className="px-4 pb-3">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar plantilla..."
className="w-full bg-neutral-800/60 border border-neutral-700/50 rounded-lg pl-8 pr-3 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-violet-500/40 transition-colors"
/>
</div>
</div>
{/* Grid — split into custom and preset sections */}
<div className="flex-1 overflow-y-auto px-4 pb-4 custom-scrollbar">
{/* ── Mis plantillas (custom) ── */}
{customTemplates.length > 0 && (
<div className="mb-3">
<button
onClick={() => setCustomOpen(prev => !prev)}
title={customOpen ? 'Colapsar mis plantillas' : 'Expandir mis plantillas'}
className="flex items-center gap-1.5 w-full mb-2 group"
>
{customOpen ? (
<ChevronDown size={10} className="text-neutral-500 group-hover:text-violet-400 transition-colors shrink-0" />
) : (
<ChevronRight size={10} className="text-neutral-500 group-hover:text-violet-400 transition-colors shrink-0" />
)}
<Star size={10} className="text-violet-400 shrink-0" />
<span className="text-[9px] uppercase tracking-wider font-semibold text-neutral-400 group-hover:text-violet-400 transition-colors">
Mis plantillas
</span>
<span className="text-[9px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{customTemplates.length}
</span>
</button>
{customOpen && (
<div className="grid grid-cols-2 gap-2">
{customTemplates.map(template => (
<DraggableTemplate
key={template.id}
template={template}
onSelect={onSelect}
onEditTemplate={onEditTemplate}
onDuplicateTemplate={onDuplicateTemplate}
onDeleteTemplate={onDeleteTemplate}
/>
))}
</div>
)}
</div>
)}
{/* Separator between sections */}
{customTemplates.length > 0 && presetTemplates.length > 0 && (
<hr className="border-neutral-800/50 my-2" />
)}
{/* ── Predeterminadas (preset) ── */}
{presetTemplates.length > 0 && (
<div>
<button
onClick={() => setPresetOpen(prev => !prev)}
title={presetOpen ? 'Colapsar predeterminadas' : 'Expandir predeterminadas'}
className="flex items-center gap-1.5 w-full mb-2 group"
>
{presetOpen ? (
<ChevronDown size={10} className="text-neutral-500 group-hover:text-neutral-300 transition-colors shrink-0" />
) : (
<ChevronRight size={10} className="text-neutral-500 group-hover:text-neutral-300 transition-colors shrink-0" />
)}
<Package size={10} className="text-neutral-500 shrink-0" />
<span className="text-[9px] uppercase tracking-wider font-semibold text-neutral-500 group-hover:text-neutral-300 transition-colors">
Predeterminadas
</span>
<span className="text-[9px] bg-neutral-800 text-neutral-500 px-1.5 py-0.5 rounded-full font-mono">
{presetTemplates.length}
</span>
</button>
{presetOpen && (
<div className="grid grid-cols-2 gap-2">
{presetTemplates.map(template => (
<DraggableTemplate
key={template.id}
template={template}
onSelect={onSelect}
onEditTemplate={onEditTemplate}
onDuplicateTemplate={onDuplicateTemplate}
onDeleteTemplate={onDeleteTemplate}
/>
))}
</div>
)}
</div>
)}
{filtered.length === 0 && search.trim() && (
<div className="text-center py-6 text-neutral-600">
<Search size={20} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">Sin resultados para "{search}"</p>
</div>
)}
{templates.length === 0 && (
<div className="text-center py-8 text-neutral-600">
<Layers size={24} className="mx-auto mb-2 opacity-40" />
<p className="text-xs">No hay plantillas creadas</p>
<p className="text-[10px] mt-1 text-neutral-700">Haz clic en "Nueva" para empezar</p>
</div>
)}
</div>
</div>
);
};
/* ── Create Template Popover ── */
const CreateTemplatePopover = React.forwardRef<
HTMLDivElement,
{
onCreateTemplate: (format: 'video' | 'image', aspect: ExpressTemplate['aspectRatio']) => void;
onClose: () => void;
}
>(({ onCreateTemplate, onClose }, ref) => {
const [selectedFormat, setSelectedFormat] = useState<'video' | 'image'>('image');
const [selectedAspect, setSelectedAspect] = useState<ExpressTemplate['aspectRatio']>('9:16');
// Focus trap: cycle focus within the popover
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== 'Tab') return;
const popover = (ref as React.RefObject<HTMLDivElement>)?.current;
if (!popover) return;
const focusable = popover.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}, [ref]);
return (
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-label="Nueva plantilla"
onKeyDown={handleKeyDown}
className="absolute right-4 top-[44px] z-50 w-[280px] p-4 bg-neutral-900 border border-neutral-700/60 rounded-xl shadow-2xl shadow-black/50 animate-in fade-in slide-in-from-top-1 duration-150 space-y-3"
>
{/* Title */}
<p className="text-sm font-bold text-white">Nueva plantilla</p>
{/* Format selector */}
<div className="space-y-1.5">
<p className="text-[10px] text-neutral-500 font-semibold uppercase tracking-wider">Tipo</p>
<div className="flex gap-1 p-0.5 bg-neutral-800/60 rounded-lg border border-neutral-700/40">
<button
onClick={() => setSelectedFormat('image')}
title="Formato imagen"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-semibold transition-all ${
selectedFormat === 'image'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<ImageIcon size={13} /> Imagen
</button>
<button
onClick={() => setSelectedFormat('video')}
title="Formato video"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[11px] font-semibold transition-all ${
selectedFormat === 'video'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<Video size={13} /> Video
</button>
</div>
</div>
{/* Aspect ratio selector */}
<div className="space-y-1.5">
<p className="text-[10px] text-neutral-500 font-semibold uppercase tracking-wider">Aspecto</p>
<div className="grid grid-cols-2 gap-1.5">
{ASPECTS.map(a => {
const isSelected = selectedAspect === a.value;
return (
<button
key={a.value}
onClick={() => setSelectedAspect(a.value)}
title={`${a.label}${a.desc}`}
className={`group flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all ${
isSelected
? 'border-violet-500/60 bg-violet-950/30 text-violet-300'
: 'border-neutral-700/50 bg-neutral-800/40 hover:bg-neutral-800 hover:border-neutral-600/60 text-neutral-400'
}`}
>
{/* Aspect ratio visual thumbnail */}
<div
className={`rounded border shrink-0 transition-colors ${
isSelected ? 'border-violet-500/60' : 'border-neutral-600 group-hover:border-neutral-500'
}`}
style={{
width: a.value === '16:9' ? 28 : a.value === '1:1' ? 18 : a.value === '4:5' ? 16 : 14,
height: a.value === '9:16' ? 24 : a.value === '1:1' ? 18 : a.value === '4:5' ? 20 : 16,
backgroundColor: isSelected ? 'rgba(139,92,246,0.15)' : 'rgba(255,255,255,0.04)',
}}
/>
<div className="text-left min-w-0">
<span className={`text-[10px] font-bold block transition-colors ${
isSelected ? 'text-violet-300' : 'text-white group-hover:text-violet-300'
}`}>
{a.label}
</span>
<span className="text-[8px] text-neutral-500 block truncate">{a.desc}</span>
</div>
</button>
);
})}
</div>
</div>
{/* Create button */}
<button
onClick={() => onCreateTemplate(selectedFormat, selectedAspect)}
title="Crear plantilla con los parámetros seleccionados"
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white text-xs font-bold transition-all shadow-lg shadow-violet-900/30 hover:shadow-violet-900/50"
>
Crear plantilla <ArrowRight size={13} />
</button>
</div>
);
});
CreateTemplatePopover.displayName = 'CreateTemplatePopover';
/* ── Draggable template thumbnail ── */
const DraggableTemplate: React.FC<{
template: ExpressTemplate;
onSelect: (t: ExpressTemplate) => void;
onEditTemplate: (t: ExpressTemplate) => void;
onDuplicateTemplate: (t: ExpressTemplate) => void;
onDeleteTemplate: (id: string) => void;
}> = ({ template, onSelect, onEditTemplate, onDuplicateTemplate, onDeleteTemplate }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
isDragging,
} = useDraggable({
id: `template-${template.id}`,
data: { type: 'template', template },
});
const style = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : 1,
};
const isCustom = template.isCustom === true;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onClick={() => onSelect(template)}
title={template.description || template.name}
className={`
group relative rounded-xl border overflow-hidden cursor-grab active:cursor-grabbing
transition-all duration-150
${isDragging
? 'border-violet-500/60 shadow-xl shadow-violet-900/30 z-50'
: 'border-neutral-800/60 bg-neutral-950/50 hover:border-violet-500/30 hover:shadow-md hover:shadow-violet-900/10'
}
`}
>
{/* Preview area */}
<div className="h-[72px] relative flex items-center justify-center bg-neutral-900/80">
<span className="text-2xl opacity-60 group-hover:opacity-100 group-hover:scale-110 transition-all">
{template.icon}
</span>
{/* Format badge */}
<div className={`absolute top-1.5 right-1.5 px-1 py-0.5 rounded text-[7px] font-bold ${
template.format === 'video'
? 'bg-violet-500/20 text-violet-300'
: 'bg-sky-500/20 text-sky-300'
}`}>
{template.format === 'video' ? '🎬' : '🖼️'} {template.aspectRatio}
</div>
{/* Drag grip hint */}
<div className="absolute top-1.5 left-1.5 opacity-0 group-hover:opacity-60 transition-opacity">
<GripVertical size={10} className="text-neutral-500" />
</div>
</div>
{/* Info */}
<div className="px-2.5 py-2 bg-neutral-950/80">
<p className="text-[10px] font-bold text-white truncate group-hover:text-violet-300 transition-colors">
{template.name}
</p>
<p className="text-[8px] text-neutral-500 truncate mt-0.5">
{template.description || `${template.category} · ${template.scenes.length} escenas`}
</p>
</div>
{/* Hover actions */}
<div className="absolute bottom-1.5 right-1.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{isCustom && (
<button
onClick={(e) => { e.stopPropagation(); onEditTemplate(template); }}
title="Editar plantilla"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Pencil size={9} />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); onDuplicateTemplate(template); }}
title="Duplicar plantilla"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-violet-400 rounded transition-colors"
>
<Copy size={9} />
</button>
{isCustom && (
<button
onClick={(e) => { e.stopPropagation(); onDeleteTemplate(template.id); }}
title="Eliminar plantilla"
className="w-5 h-5 flex items-center justify-center bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-red-400 rounded transition-colors"
>
<Trash2 size={9} />
</button>
)}
</div>
</div>
);
};
/**
* DragOverlay content for a template being dragged.
* Used by the parent DndContext's DragOverlay.
*/
export const TemplateDragPreview: React.FC<{ template: ExpressTemplate }> = ({ template }) => (
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-neutral-800/95 border border-violet-500/50 shadow-2xl shadow-violet-900/40 backdrop-blur-sm">
<span className="text-xl">{template.icon}</span>
<div>
<p className="text-xs font-bold text-white">{template.name}</p>
<p className="text-[9px] text-violet-400">{template.format === 'video' ? '🎬 Video' : '🖼️ Imagen'} · {template.aspectRatio}</p>
</div>
</div>
);
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import { Download, Loader2, CheckCircle2, XCircle, Clock, Trash2, X } from 'lucide-react';
import type { RenderJobClient } from '../../hooks/useExportQueue';
interface ExportJobItemProps {
job: RenderJobClient;
onCancel: (id: string) => void;
onDownload: (job: RenderJobClient) => void;
}
/**
* Individual export job card with progress bar, status badge, and actions.
*/
export const ExportJobItem: React.FC<ExportJobItemProps> = ({ job, onCancel, onDownload }) => {
const elapsed = job.startedAt
? ((job.completedAt ?? Date.now()) - job.startedAt) / 1000
: 0;
const formatLabel = {
mp4: 'MP4 Video',
webm: 'WebM Video',
gif: 'GIF Animación',
png: 'PNG Image',
jpeg: 'JPEG Image',
}[job.format];
const statusConfig = {
queued: { icon: Clock, color: 'text-amber-400', bg: 'bg-amber-500/10', label: 'En cola' },
rendering: { icon: Loader2, color: 'text-violet-400', bg: 'bg-violet-500/10', label: 'Renderizando' },
done: { icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-500/10', label: 'Completado' },
error: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/10', label: 'Error' },
}[job.status];
const StatusIcon = statusConfig.icon;
return (
<div className="bg-neutral-900/80 border border-neutral-800/60 rounded-xl p-3 space-y-2.5 transition-all hover:border-neutral-700/60">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusIcon
size={14}
className={`${statusConfig.color} ${job.status === 'rendering' ? 'animate-spin' : ''}`}
/>
<span className="text-[11px] font-semibold text-white">{formatLabel}</span>
<span className="text-[9px] font-mono text-neutral-600">{job.id.slice(0, 8)}</span>
</div>
<div className="flex items-center gap-1">
{job.status === 'done' && (
<button
onClick={() => onDownload(job)}
title="Descargar"
className="p-1 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
>
<Download size={12} />
</button>
)}
{(job.status === 'queued' || job.status === 'rendering') && (
<button
onClick={() => onCancel(job.id)}
title="Cancelar"
className="p-1 rounded-md text-neutral-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X size={12} />
</button>
)}
{(job.status === 'done' || job.status === 'error') && (
<button
onClick={() => onCancel(job.id)}
title="Eliminar"
className="p-1 rounded-md text-neutral-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 size={10} />
</button>
)}
</div>
</div>
{/* Progress bar */}
{(job.status === 'rendering' || job.status === 'queued') && (
<div className="space-y-1">
<div className="h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
job.status === 'queued'
? 'bg-amber-500/50 animate-pulse'
: 'bg-gradient-to-r from-violet-500 to-fuchsia-500'
}`}
style={{ width: `${Math.max(job.status === 'queued' ? 5 : job.progress, 2)}%` }}
/>
</div>
<div className="flex items-center justify-between text-[9px] text-neutral-500">
<span>{job.progress}%</span>
{job.renderedFrames != null && job.totalFrames != null && (
<span>{job.renderedFrames}/{job.totalFrames} frames</span>
)}
</div>
</div>
)}
{/* Status badge */}
<div className="flex items-center justify-between">
<span className={`text-[9px] px-2 py-0.5 rounded-full font-medium ${statusConfig.bg} ${statusConfig.color}`}>
{statusConfig.label}
</span>
<div className="flex items-center gap-2 text-[9px] text-neutral-600">
<span>{job.width}×{job.height}</span>
{job.fps && <span>{job.fps}fps</span>}
{elapsed > 0 && <span>{elapsed.toFixed(1)}s</span>}
</div>
</div>
{/* Error message */}
{job.error && (
<div className="text-[10px] text-red-400/80 bg-red-500/5 rounded-md px-2 py-1.5 border border-red-500/10">
{job.error}
</div>
)}
</div>
);
};
+320
View File
@@ -0,0 +1,320 @@
import React, { useState, useMemo } from 'react';
import { X, Download, Film, Image as ImageIcon, Wifi, WifiOff, Zap, ChevronDown } from 'lucide-react';
import { useExportQueue, RenderFormat, ExportConfig } from '../../hooks/useExportQueue';
import { ExportJobItem } from './ExportJobItem';
import type { DesignMD, TimelineElement, TimelineLayer } from '../../types';
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
designMD: DesignMD;
textOverlay: string;
timelineElements: TimelineElement[];
layers: TimelineLayer[];
durationInFrames: number;
brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
outputFormat?: 'video' | 'image';
/** Template aspect ratio — used to filter resolution presets */
aspectRatio?: '9:16' | '16:9' | '1:1' | '4:5' | '4:3';
}
const FORMAT_OPTIONS: { value: RenderFormat; label: string; icon: typeof Film; desc: string }[] = [
{ value: 'mp4', label: 'MP4', icon: Film, desc: 'Compatible con todo' },
{ value: 'webm', label: 'WebM', icon: Film, desc: 'Web optimizado' },
{ value: 'gif', label: 'GIF', icon: Film, desc: 'Animación ligera' },
{ value: 'png', label: 'PNG', icon: ImageIcon, desc: 'Imagen sin fondo' },
{ value: 'jpeg', label: 'JPEG', icon: ImageIcon, desc: 'Imagen comprimida' },
];
const RESOLUTION_PRESETS = [
{ label: '1080×1080', w: 1080, h: 1080, desc: 'Instagram Post', ratio: '1:1' },
{ label: '720×720', w: 720, h: 720, desc: 'Preview rápido', ratio: '1:1' },
{ label: '1080×1920', w: 1080, h: 1920, desc: 'Story / Reel', ratio: '9:16' },
{ label: '720×1280', w: 720, h: 1280, desc: 'Preview rápido', ratio: '9:16' },
{ label: '1920×1080', w: 1920, h: 1080, desc: 'YouTube / TV', ratio: '16:9' },
{ label: '1280×720', w: 1280, h: 720, desc: 'HD 720p', ratio: '16:9' },
{ label: '1080×1350', w: 1080, h: 1350, desc: 'Feed 4:5', ratio: '4:5' },
{ label: '720×900', w: 720, h: 900, desc: 'Preview 4:5', ratio: '4:5' },
{ label: '1440×1080', w: 1440, h: 1080, desc: 'Pantalla 4:3', ratio: '4:3' },
{ label: '960×720', w: 960, h: 720, desc: 'Preview 4:3', ratio: '4:3' },
];
/**
* Export modal format selection, resolution presets, and live job queue.
*/
export const ExportModal: React.FC<ExportModalProps> = ({
isOpen,
onClose,
designMD,
textOverlay,
timelineElements,
layers,
durationInFrames,
brandVisibility,
outputFormat,
aspectRatio,
}) => {
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
const [format, setFormat] = useState<RenderFormat>('mp4');
const [fps, setFps] = useState(30);
const [isExporting, setIsExporting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [quality, setQuality] = useState<'draft' | 'standard' | 'high' | 'ultra'>('high');
const isStill = format === 'png' || format === 'jpeg';
// Filter resolution presets to match the template's aspect ratio
const filteredPresets = useMemo(() => {
if (!aspectRatio) return RESOLUTION_PRESETS;
const matching = RESOLUTION_PRESETS.filter(p => p.ratio === aspectRatio);
return matching.length > 0 ? matching : RESOLUTION_PRESETS;
}, [aspectRatio]);
const [resIdx, setResIdx] = useState(0);
const selectedRes = filteredPresets[resIdx] || filteredPresets[0];
// Estimated file size
const estimatedSize = useMemo(() => {
if (isStill) return '~0.5 MB';
const seconds = durationInFrames / fps;
const pixels = selectedRes.w * selectedRes.h;
const bitrateMap: Record<string, number> = { mp4: 5, webm: 3, gif: 15 };
const bitrate = bitrateMap[format] ?? 5; // MB per minute per megapixel
const mp = pixels / 1_000_000;
const sizeMB = (seconds / 60) * bitrate * mp;
return sizeMB < 1 ? `~${Math.round(sizeMB * 1024)} KB` : `~${sizeMB.toFixed(1)} MB`;
}, [format, selectedRes, durationInFrames, fps, isStill]);
// Auto-select image format in image mode
const filteredFormats = useMemo(() => {
if (outputFormat === 'image') {
return FORMAT_OPTIONS.filter(f => f.value === 'png' || f.value === 'jpeg');
}
return FORMAT_OPTIONS;
}, [outputFormat]);
const handleExport = async () => {
setIsExporting(true);
try {
const config: ExportConfig = {
format,
width: selectedRes.w,
height: selectedRes.h,
fps,
durationInFrames: isStill ? 1 : durationInFrames,
designMD,
textOverlay,
timelineElements,
layers,
brandVisibility,
outputFormat,
};
await startExport(config);
} finally {
setIsExporting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl shadow-2xl shadow-black/80 w-[480px] max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-gradient-to-br from-violet-500/20 to-fuchsia-500/20 border border-violet-500/20">
<Download size={18} className="text-violet-400" />
</div>
<div>
<h2 className="text-sm font-bold text-white">Exportar</h2>
<p className="text-[10px] text-neutral-500">Renderizar y descargar</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5" title={isConnected ? 'Conectado al servidor' : 'Sin conexión'}>
{isConnected
? <Wifi size={12} className="text-emerald-400" />
: <WifiOff size={12} className="text-red-400" />
}
<span className={`text-[9px] ${isConnected ? 'text-emerald-400' : 'text-red-400'}`}>
{isConnected ? 'Live' : 'Offline'}
</span>
</div>
<button onClick={onClose} title="Cerrar" className="p-1.5 rounded-lg hover:bg-neutral-800 transition-colors text-neutral-400 hover:text-white">
<X size={16} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5 space-y-5">
{/* Format Selection */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Formato</label>
<div className="grid grid-cols-2 gap-2">
{filteredFormats.map(opt => {
const Icon = opt.icon;
return (
<button
key={opt.value}
onClick={() => setFormat(opt.value)}
title={opt.desc}
className={`p-3 rounded-xl border transition-all flex items-center gap-2.5 ${
format === opt.value
? 'bg-violet-600/15 border-violet-500/50 text-violet-300 shadow-lg shadow-violet-500/5'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-300'
}`}
>
<Icon size={16} />
<div className="text-left">
<div className="text-xs font-semibold">{opt.label}</div>
<div className="text-[9px] text-neutral-500">{opt.desc}</div>
</div>
</button>
);
})}
</div>
</div>
{/* Resolution */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Resolución</label>
<div className="flex flex-wrap gap-1.5">
{filteredPresets.map((preset, idx) => (
<button
key={preset.label}
onClick={() => setResIdx(idx)}
title={preset.desc}
className={`px-3 py-1.5 rounded-lg border text-[10px] font-medium transition-all ${
resIdx === idx
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
{preset.label}
</button>
))}
</div>
<p className="text-[9px] text-neutral-600 mt-1">{selectedRes.desc}</p>
</div>
{/* Advanced Settings */}
{!isStill && (
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1 text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
<ChevronDown size={10} className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
Configuración avanzada
</button>
{showAdvanced && (
<div className="mt-2 bg-neutral-900/50 border border-neutral-800/50 rounded-lg p-3 space-y-3">
<div>
<label className="block text-[10px] text-neutral-500 mb-1">FPS</label>
<div className="flex gap-1.5">
{[24, 30, 60].map(f => (
<button
key={f}
onClick={() => setFps(f)}
className={`px-3 py-1 rounded-md text-[10px] font-medium border transition-all ${
fps === f
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
{f} fps
</button>
))}
</div>
</div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-500">Duración</span>
<span className="text-neutral-400 font-mono">{(durationInFrames / fps).toFixed(1)}s ({durationInFrames} frames)</span>
</div>
{/* Quality Tier */}
<div>
<label className="block text-[10px] text-neutral-500 mb-1">Calidad</label>
<div className="grid grid-cols-4 gap-1">
{[
{ value: 'draft' as const, label: 'Draft', color: 'text-neutral-400' },
{ value: 'standard' as const, label: 'Std', color: 'text-sky-400' },
{ value: 'high' as const, label: 'High', color: 'text-violet-400' },
{ value: 'ultra' as const, label: 'Ultra', color: 'text-amber-400' },
].map(q => (
<button
key={q.value}
onClick={() => {
setQuality(q.value);
// Auto-adjust resolution: high/ultra → first (largest), draft/standard → last (smallest)
const lastIdx = filteredPresets.length - 1;
const resMap: Record<string, number> = { draft: lastIdx, standard: lastIdx, high: 0, ultra: 0 };
setResIdx(resMap[q.value] ?? 0);
const fpsMap: Record<string, number> = { draft: 24, standard: 30, high: 30, ultra: 60 };
setFps(fpsMap[q.value] ?? 30);
}}
title={q.label}
className={`py-1 rounded-md text-[9px] font-semibold border transition-all ${
quality === q.value
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: `bg-neutral-900 border-neutral-800 ${q.color} hover:border-neutral-700`
}`}
>
{q.label}
</button>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* Export Button */}
<button
onClick={handleExport}
disabled={isExporting || !isConnected}
title="Iniciar exportación"
className={`w-full py-3 rounded-xl font-semibold text-sm flex items-center justify-center gap-2 transition-all ${
isExporting || !isConnected
? 'bg-neutral-800 text-neutral-500 cursor-not-allowed'
: 'bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white hover:from-violet-500 hover:to-fuchsia-500 shadow-lg shadow-violet-500/20 hover:shadow-violet-500/30'
}`}
>
<Zap size={16} />
{isExporting ? 'Iniciando...' : `Exportar ${isStill ? 'Imagen' : 'Video'}`}
<span className="text-[9px] opacity-60 font-mono">({estimatedSize})</span>
</button>
{/* Job Queue */}
{jobs.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">
Cola de Exportación
</label>
{hasActiveJobs && (
<span className="text-[9px] text-violet-400 animate-pulse">
{activeJobs.length} activ{activeJobs.length > 1 ? 'os' : 'o'}
</span>
)}
</div>
<div className="space-y-2 max-h-[250px] overflow-y-auto custom-scrollbar">
{jobs.map(job => (
<ExportJobItem
key={job.id}
job={job}
onCancel={cancelJob}
onDownload={downloadJob}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,59 @@
import React from 'react';
import { Clock } from 'lucide-react';
interface ExpressDurationPickerProps {
duration: number;
onChange: (seconds: number) => void;
isVideo: boolean;
}
const VIDEO_PRESETS = [5, 10, 15, 20, 30, 60];
/**
* ExpressDurationPicker Simple duration selector with presets.
* Only visible for video templates (image duration is fixed at 1s).
*/
export const ExpressDurationPicker: React.FC<ExpressDurationPickerProps> = ({
duration,
onChange,
isVideo,
}) => {
if (!isVideo) return null;
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Clock size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Duración</span>
<span className="text-[9px] text-violet-400 font-mono ml-auto">{duration}s</span>
</div>
<div className="grid grid-cols-3 gap-1">
{VIDEO_PRESETS.map(s => (
<button
key={s}
onClick={() => onChange(s)}
title={`${s} segundos`}
className={`py-1.5 rounded-lg text-[10px] font-semibold border transition-all ${
duration === s
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-white hover:border-neutral-700'
}`}
>
{s}s
</button>
))}
</div>
{/* Visual bar */}
<div className="flex gap-0.5 h-1.5 rounded-full overflow-hidden">
<div className="bg-amber-500/40 rounded-l-full" style={{ width: '15%' }} title="Intro" />
<div className="bg-violet-500/40 flex-1" title="Contenido" />
<div className="bg-amber-500/40 rounded-r-full" style={{ width: '15%' }} title="Outro" />
</div>
<div className="flex justify-between text-[7px] text-neutral-600 px-1">
<span>Intro</span>
<span>Contenido</span>
<span>Outro</span>
</div>
</div>
);
};
+328
View File
@@ -0,0 +1,328 @@
import React, { useState, useMemo, useCallback, useRef } from 'react';
import { ArrowLeft, Zap, Wrench, Download, ChevronRight, Play, Pause, RotateCcw } from 'lucide-react';
import { Player, PlayerRef } from '@remotion/player';
import { ExpressTemplate, DesignMD, TimelineElement, TimelineLayer, CompanyProfile } from '../../types';
import { BrandComposition } from '../BrandComposition';
import { ExpressTemplateGallery } from './ExpressTemplateGallery';
import { StoryboardView } from './StoryboardView';
import { SceneFieldEditor } from './SceneFieldEditor';
import { ExpressStylePanel } from './ExpressStylePanel';
import { compileExpressToTimeline, getAspectDimensions, getTemplateDuration } from '../../utils/expressCompiler';
interface ExpressEditorProps {
designMD: DesignMD;
company?: CompanyProfile;
onBack: () => void;
onUpgradeToPro: (elements: TimelineElement[], layers: TimelineLayer[]) => void;
onExport: (elements: TimelineElement[], layers: TimelineLayer[], format: 'video' | 'image') => void;
}
type EditorPhase = 'gallery' | 'editing';
/**
* ExpressEditor Scene-based storyboard editor.
* No video editor, no timeline, no toolbar.
* User picks a template fills in scenes exports.
*/
export const ExpressEditor: React.FC<ExpressEditorProps> = ({
designMD,
company,
onBack,
onUpgradeToPro,
onExport,
}) => {
const [phase, setPhase] = useState<EditorPhase>('gallery');
const [selectedTemplate, setSelectedTemplate] = useState<ExpressTemplate | null>(null);
const [fieldData, setFieldData] = useState<Record<string, string>>({});
const [activeSceneId, setActiveSceneId] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// Style options
const [bgStyle, setBgStyle] = useState<'solid' | 'gradient' | 'dark'>('gradient');
const [showLogo, setShowLogo] = useState(true);
const [overlayOpacity, setOverlayOpacity] = useState(0);
const playerRef = useRef<PlayerRef>(null);
const handleSelectTemplate = useCallback((template: ExpressTemplate) => {
setSelectedTemplate(template);
// Pre-fill field data with empty strings
const initial: Record<string, string> = {};
template.scenes.forEach(scene => {
scene.editableFields.forEach(field => {
initial[field.id] = '';
});
});
setFieldData(initial);
setActiveSceneId(template.scenes[0]?.id || null);
setPhase('editing');
}, []);
const handleFieldChange = useCallback((fieldId: string, value: string) => {
setFieldData(prev => ({ ...prev, [fieldId]: value }));
}, []);
// Compile template to timeline
const compiled = useMemo(() => {
if (!selectedTemplate) return null;
return compileExpressToTimeline(selectedTemplate, fieldData, designMD, company);
}, [selectedTemplate, fieldData, designMD, company]);
const totalDuration = selectedTemplate ? getTemplateDuration(selectedTemplate) : 0;
const fps = 30;
const totalFrames = Math.max(30, totalDuration * fps);
const dimensions = selectedTemplate
? getAspectDimensions(selectedTemplate.aspectRatio)
: { w: 1080, h: 1920 };
const activeScene = selectedTemplate?.scenes.find(s => s.id === activeSceneId) || null;
const handlePlayToggle = useCallback(() => {
if (playerRef.current) {
if (isPlaying) {
playerRef.current.pause();
} else {
playerRef.current.play();
}
setIsPlaying(!isPlaying);
}
}, [isPlaying]);
const handleUpgrade = () => {
if (compiled) onUpgradeToPro(compiled.elements, compiled.layers);
};
const handleExport = () => {
if (compiled && selectedTemplate) {
onExport(compiled.elements, compiled.layers, selectedTemplate.format);
}
};
// Navigate to scene in player
const handleSelectScene = useCallback((sceneId: string) => {
setActiveSceneId(sceneId);
if (!selectedTemplate || !playerRef.current) return;
// Seek player to scene start
let frameOffset = 0;
for (const scene of selectedTemplate.scenes) {
if (scene.id === sceneId) break;
frameOffset += scene.durationSeconds * fps;
}
playerRef.current.seekTo(frameOffset);
playerRef.current.pause();
setIsPlaying(false);
}, [selectedTemplate, fps]);
const bgColor = bgStyle === 'dark'
? '#111111'
: bgStyle === 'gradient'
? undefined
: designMD.secondaryColor;
return (
<div className="flex-1 flex flex-col overflow-hidden bg-neutral-950">
{/* ═══ Top Bar ═══ */}
<div className="h-11 bg-neutral-900/80 border-b border-neutral-800/60 flex items-center px-4 gap-3 shrink-0 backdrop-blur-sm">
<button
onClick={phase === 'editing' ? () => setPhase('gallery') : onBack}
title="Volver"
className="flex items-center gap-1.5 text-neutral-400 hover:text-white transition-colors text-xs"
>
<ArrowLeft size={14} />
{phase === 'editing' ? 'Plantillas' : 'Dashboard'}
</button>
<div className="flex-1" />
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-gradient-to-r from-violet-600/15 to-fuchsia-600/15 border border-violet-500/20">
<Zap size={12} className="text-violet-400" />
<span className="text-[10px] font-bold text-violet-300 tracking-wider">EXPRESS</span>
</div>
{phase === 'editing' && (
<>
<button
onClick={handleUpgrade}
title="Abrir en Editor Pro con timeline completo"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-neutral-800 border border-neutral-700 text-[10px] text-neutral-400 hover:text-white hover:border-neutral-600 transition-all"
>
<Wrench size={10} />
Editor Pro
<ChevronRight size={10} />
</button>
<button
onClick={handleExport}
title="Exportar"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-violet-600 to-fuchsia-600 text-white text-[10px] font-semibold hover:from-violet-500 hover:to-fuchsia-500 transition-all shadow-lg shadow-violet-900/30"
>
<Download size={12} />
Exportar
</button>
</>
)}
</div>
{/* ═══ Content ═══ */}
{phase === 'gallery' ? (
<ExpressTemplateGallery
designMD={designMD}
onSelectTemplate={handleSelectTemplate}
brandTemplates={company?.brandTemplates}
brandName={company?.name}
/>
) : selectedTemplate && compiled ? (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Main area: Preview + Right Panel */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Canvas Area */}
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Subtle pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Template name */}
<div className="mb-2 flex items-center gap-2 relative z-10 shrink-0">
<span className="text-lg">{selectedTemplate.icon}</span>
<span className="text-xs font-semibold text-neutral-400">{selectedTemplate.name}</span>
<span className="text-[9px] text-neutral-600 font-mono px-1.5 py-0.5 bg-neutral-900 rounded">
{selectedTemplate.aspectRatio}
</span>
<span className="text-[9px] text-neutral-600 font-mono px-1.5 py-0.5 bg-neutral-900 rounded">
{totalDuration}s
</span>
</div>
{/* Player */}
<div
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 shrink-0"
style={{
width: selectedTemplate.aspectRatio === '9:16' ? 240
: selectedTemplate.aspectRatio === '1:1' ? 320
: selectedTemplate.aspectRatio === '4:5' ? 280
: 420,
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
maxHeight: 'calc(100% - 80px)',
}}
>
<Player
ref={playerRef}
component={BrandComposition}
inputProps={{
designMD: {
...designMD,
secondaryColor: bgColor || designMD.secondaryColor,
},
timelineElements: compiled.elements,
layers: compiled.layers,
selectedElementId: null,
aspectRatio: selectedTemplate.aspectRatio,
textOverlay: '',
showLogo,
showFrame: false,
showBackground: true,
brandVisibility: {
logo: showLogo,
frame: false,
background: true,
},
}}
durationInFrames={totalFrames}
compositionWidth={dimensions.w}
compositionHeight={dimensions.h}
fps={fps}
style={{ width: '100%', height: '100%' }}
controls={false}
autoPlay={false}
loop
/>
{overlayOpacity > 0 && (
<div
className="absolute inset-0 pointer-events-none"
style={{ backgroundColor: `rgba(0,0,0,${overlayOpacity / 100})` }}
/>
)}
</div>
{/* Mini play controls */}
{selectedTemplate.format === 'video' && (
<div className="mt-3 flex items-center gap-2 relative z-10 shrink-0">
<button
onClick={handlePlayToggle}
title={isPlaying ? 'Pausar' : 'Reproducir'}
className="w-7 h-7 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-sm"
>
{isPlaying ? <Pause size={11} fill="currentColor" /> : <Play size={11} fill="currentColor" />}
</button>
<button
onClick={() => { playerRef.current?.seekTo(0); setIsPlaying(false); }}
title="Reiniciar"
className="w-6 h-6 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
>
<RotateCcw size={10} />
</button>
<span className="text-[9px] text-neutral-500 font-mono">{totalDuration}s</span>
</div>
)}
</div>
{/* Right Panel — Scene Fields */}
<aside className="w-72 bg-neutral-900 border-l border-neutral-800/60 overflow-y-auto p-4 space-y-5 shrink-0">
{activeScene ? (
<SceneFieldEditor
scene={activeScene}
fieldData={fieldData}
onFieldChange={handleFieldChange}
designMD={designMD}
/>
) : (
<div className="text-center text-neutral-500 text-xs py-8">
Selecciona una escena del storyboard
</div>
)}
<hr className="border-neutral-800/50" />
{/* Style */}
<ExpressStylePanel
designMD={designMD}
bgStyle={bgStyle}
setBgStyle={setBgStyle}
showLogo={showLogo}
setShowLogo={setShowLogo}
overlayOpacity={overlayOpacity}
setOverlayOpacity={setOverlayOpacity}
/>
<hr className="border-neutral-800/50" />
<button
onClick={() => setPhase('gallery')}
title="Elegir otra plantilla"
className="w-full py-2 rounded-lg bg-neutral-800/50 border border-neutral-800 text-[10px] text-neutral-400 hover:text-white hover:border-neutral-700 transition-all flex items-center justify-center gap-1.5"
>
<RotateCcw size={10} />
Cambiar plantilla
</button>
</aside>
</div>
{/* Storyboard (bottom strip — video only) */}
{selectedTemplate.format === 'video' && (
<StoryboardView
scenes={selectedTemplate.scenes}
activeSceneId={activeSceneId}
onSelectScene={handleSelectScene}
fieldData={fieldData}
totalDuration={totalDuration}
/>
)}
</div>
) : null}
</div>
);
};
@@ -0,0 +1,110 @@
import React from 'react';
import { Palette, Eye, EyeOff } from 'lucide-react';
import { DesignMD } from '../../types';
interface ExpressStylePanelProps {
designMD: DesignMD;
bgStyle: 'solid' | 'gradient' | 'dark';
setBgStyle: (style: 'solid' | 'gradient' | 'dark') => void;
showLogo: boolean;
setShowLogo: (show: boolean) => void;
overlayOpacity: number;
setOverlayOpacity: (opacity: number) => void;
}
/**
* ExpressStylePanel Brand-constrained style controls.
* Only allows changes within the brand palette no custom colors.
*/
export const ExpressStylePanel: React.FC<ExpressStylePanelProps> = ({
designMD,
bgStyle,
setBgStyle,
showLogo,
setShowLogo,
overlayOpacity,
setOverlayOpacity,
}) => {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<Palette size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Estilo</span>
</div>
{/* Brand colors (read-only) */}
<div className="flex items-center gap-2">
<span className="text-[9px] text-neutral-500">Paleta:</span>
{[designMD.primaryColor, designMD.secondaryColor, designMD.textColor].map((c, i) => (
<div
key={i}
className="w-5 h-5 rounded-md border border-neutral-700 shadow-sm"
style={{ backgroundColor: c }}
title={c}
/>
))}
<span className="text-[7px] text-neutral-600 ml-auto font-mono">{designMD.baseFont.split(',')[0].replace(/"/g, '')}</span>
</div>
{/* Background style */}
<div className="space-y-1">
<span className="text-[9px] text-neutral-500">Fondo</span>
<div className="grid grid-cols-3 gap-1">
{([
{ value: 'solid' as const, label: 'Sólido', preview: designMD.secondaryColor },
{ value: 'gradient' as const, label: 'Degradado', preview: `linear-gradient(135deg, ${designMD.primaryColor}, ${designMD.secondaryColor})` },
{ value: 'dark' as const, label: 'Oscuro', preview: '#111111' },
]).map(bg => (
<button
key={bg.value}
onClick={() => setBgStyle(bg.value)}
title={bg.label}
className={`relative h-8 rounded-lg border overflow-hidden transition-all ${
bgStyle === bg.value
? 'border-violet-500/60 ring-1 ring-violet-500/20'
: 'border-neutral-800 hover:border-neutral-700'
}`}
>
<div
className="absolute inset-0"
style={{ background: bg.preview }}
/>
<span className="relative text-[8px] font-semibold text-white drop-shadow-md">{bg.label}</span>
</button>
))}
</div>
</div>
{/* Overlay opacity */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[9px] text-neutral-500">Overlay</span>
<span className="text-[8px] text-neutral-600 font-mono">{overlayOpacity}%</span>
</div>
<input
type="range"
min={0}
max={80}
value={overlayOpacity}
onChange={(e) => setOverlayOpacity(Number(e.target.value))}
className="w-full h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
title="Opacidad del overlay oscuro"
/>
</div>
{/* Logo toggle */}
<button
onClick={() => setShowLogo(!showLogo)}
title={showLogo ? 'Ocultar logo' : 'Mostrar logo'}
className={`w-full flex items-center justify-between py-1.5 px-2.5 rounded-lg border transition-all ${
showLogo
? 'bg-violet-600/10 border-violet-500/30 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<span className="text-[9px] font-medium">Logo de marca</span>
{showLogo ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
</div>
);
};
@@ -0,0 +1,252 @@
import React, { useState, useMemo } from 'react';
import { Sparkles, Filter, Video, Image as ImageIcon } from 'lucide-react';
import { ExpressTemplate, DesignMD } from '../../types';
import { EXPRESS_TEMPLATES } from '../../config/expressTemplates';
import { getTemplateDuration } from '../../utils/expressCompiler';
interface ExpressTemplateGalleryProps {
designMD: DesignMD;
onSelectTemplate: (template: ExpressTemplate) => void;
customTemplates?: ExpressTemplate[];
brandTemplates?: ExpressTemplate[];
brandName?: string;
}
type CategoryFilter = 'all' | ExpressTemplate['category'];
type FormatFilter = 'all' | 'video' | 'image';
const CATEGORY_LABELS: Record<string, { label: string; icon: string }> = {
all: { label: 'Todos', icon: '✨' },
social: { label: 'Social', icon: '📱' },
ad: { label: 'Publicidad', icon: '🎯' },
promo: { label: 'Promo', icon: '🚀' },
story: { label: 'Historia', icon: '💬' },
announcement: { label: 'Anuncio', icon: '📢' },
};
/**
* ExpressTemplateGallery Grid of Express templates with category/format filters.
* Shows previews using brand colors and allows template selection.
*/
export const ExpressTemplateGallery: React.FC<ExpressTemplateGalleryProps> = ({
designMD,
onSelectTemplate,
customTemplates = [],
brandTemplates = [],
brandName,
}) => {
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const [formatFilter, setFormatFilter] = useState<FormatFilter>('all');
const allTemplates = useMemo(() => [
...EXPRESS_TEMPLATES,
...customTemplates,
], [customTemplates]);
const filtered = useMemo(() => {
return allTemplates.filter(t => {
if (categoryFilter !== 'all' && t.category !== categoryFilter) return false;
if (formatFilter !== 'all' && t.format !== formatFilter) return false;
return true;
});
}, [allTemplates, categoryFilter, formatFilter]);
return (
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-gradient-to-br from-violet-600/20 to-fuchsia-600/20 border border-violet-500/20">
<Sparkles size={20} className="text-violet-400" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Elige una Plantilla</h2>
<p className="text-xs text-neutral-500">
Se aplicarán los colores y fuentes de <span className="text-violet-400">{designMD.brandName || 'tu marca'}</span> automáticamente
</p>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
{/* Format toggle */}
<div className="flex rounded-lg bg-neutral-900 border border-neutral-800 p-0.5 shrink-0">
{(['all', 'video', 'image'] as FormatFilter[]).map(f => (
<button
key={f}
onClick={() => setFormatFilter(f)}
title={f === 'all' ? 'Todos los formatos' : f === 'video' ? 'Solo video' : 'Solo imagen'}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-[10px] font-semibold transition-all ${
formatFilter === f
? 'bg-violet-600/20 text-violet-300 border border-violet-500/30'
: 'text-neutral-500 hover:text-neutral-300 border border-transparent'
}`}
>
{f === 'all' && <Filter size={10} />}
{f === 'video' && <Video size={10} />}
{f === 'image' && <ImageIcon size={10} />}
{f === 'all' ? 'Todo' : f === 'video' ? 'Video' : 'Imagen'}
</button>
))}
</div>
{/* Category pills */}
<div className="flex gap-1 flex-wrap">
{Object.entries(CATEGORY_LABELS).map(([key, { label, icon }]) => (
<button
key={key}
onClick={() => setCategoryFilter(key as CategoryFilter)}
title={`Filtrar por: ${label}`}
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-[9px] font-medium transition-all border ${
categoryFilter === key
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:text-neutral-300 hover:border-neutral-700'
}`}
>
<span>{icon}</span> {label}
</button>
))}
</div>
</div>
{/* Brand Templates Section */}
{brandTemplates.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="text-xs font-bold text-amber-300">
🏷 Plantillas de {brandName || 'tu marca'}
</h3>
<span className="text-[8px] text-neutral-600 bg-neutral-800 px-1.5 py-0.5 rounded font-mono">
{brandTemplates.length}
</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{brandTemplates.map(template => (
<button
key={template.id}
onClick={() => onSelectTemplate(template)}
title={template.description}
className="group relative bg-amber-500/5 border border-amber-500/20 rounded-xl overflow-hidden hover:border-amber-400/50 hover:shadow-lg hover:shadow-amber-900/10 transition-all hover:scale-[1.02] active:scale-[0.98] text-left"
>
<div
className="h-32 relative flex items-center justify-center overflow-hidden"
style={{
background: `linear-gradient(135deg, ${designMD.secondaryColor}40 0%, ${designMD.primaryColor}20 100%)`,
}}
>
<span className="text-3xl opacity-60 group-hover:opacity-100 transition-all">{template.icon}</span>
<div className="absolute top-2 left-2 px-1.5 py-0.5 rounded bg-amber-500/20 text-[8px] text-amber-300 font-bold">
🏷 {brandName}
</div>
<div className={`absolute top-2 right-2 px-1.5 py-0.5 rounded text-[8px] font-semibold backdrop-blur-sm ${
template.format === 'video' ? 'bg-violet-500/20 text-violet-300' : 'bg-sky-500/20 text-sky-300'
}`}>
{template.format === 'video' ? '🎬' : '🖼️'} {template.aspectRatio}
</div>
</div>
<div className="p-3">
<h4 className="text-xs font-bold text-white group-hover:text-amber-300 transition-colors">{template.name}</h4>
<p className="text-[9px] text-neutral-500 mt-0.5 line-clamp-1">{template.description}</p>
</div>
</button>
))}
</div>
</div>
)}
{/* General Templates */}
{brandTemplates.length > 0 && (
<h3 className="text-xs font-bold text-neutral-400">Plantillas Generales</h3>
)}
{/* Template Grid */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{filtered.map(template => (
<button
key={template.id}
onClick={() => onSelectTemplate(template)}
title={template.description}
className="group relative bg-neutral-900/60 border border-neutral-800/60 rounded-xl overflow-hidden hover:border-violet-500/40 hover:shadow-lg hover:shadow-violet-900/10 transition-all hover:scale-[1.02] active:scale-[0.98] text-left"
>
{/* Preview area — branded colors */}
<div
className="h-40 relative flex items-center justify-center overflow-hidden"
style={{
background: `linear-gradient(135deg, ${designMD.secondaryColor}40 0%, ${designMD.primaryColor}20 100%)`,
}}
>
{/* Template icon */}
<span className="text-4xl opacity-60 group-hover:opacity-100 group-hover:scale-110 transition-all">
{template.icon}
</span>
{/* Aspect ratio badge */}
<div className="absolute top-2 left-2 px-1.5 py-0.5 rounded bg-black/40 backdrop-blur-sm text-[8px] text-neutral-300 font-mono">
{template.aspectRatio}
</div>
{/* Format badge */}
<div className={`absolute top-2 right-2 px-1.5 py-0.5 rounded text-[8px] font-semibold backdrop-blur-sm ${
template.format === 'video'
? 'bg-violet-500/20 text-violet-300'
: 'bg-sky-500/20 text-sky-300'
}`}>
{template.format === 'video' ? '🎬 Video' : '🖼️ Imagen'}
</div>
{/* Duration badge */}
{template.format === 'video' && (
<div className="absolute bottom-2 right-2 px-1.5 py-0.5 rounded bg-black/40 backdrop-blur-sm text-[8px] text-neutral-300 font-mono">
{getTemplateDuration(template)}s
</div>
)}
{/* Custom badge */}
{template.isCustom && (
<div className="absolute bottom-2 left-2 px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-300 text-[7px] font-bold uppercase tracking-wider">
Custom
</div>
)}
{/* Brand color dots */}
<div className="absolute bottom-2 left-2 flex gap-1">
{!template.isCustom && (
<>
<div className="w-2.5 h-2.5 rounded-full border border-white/20 shadow-sm" style={{ backgroundColor: designMD.primaryColor }} />
<div className="w-2.5 h-2.5 rounded-full border border-white/20 shadow-sm" style={{ backgroundColor: designMD.secondaryColor }} />
</>
)}
</div>
</div>
{/* Info */}
<div className="p-3">
<h3 className="text-xs font-bold text-white group-hover:text-violet-300 transition-colors">{template.name}</h3>
<p className="text-[9px] text-neutral-500 mt-0.5 line-clamp-1">{template.description}</p>
<div className="flex items-center gap-1.5 mt-2">
{template.scenes.map(scene => (
<span
key={scene.id}
className={`text-[7px] px-1 py-0.5 rounded uppercase tracking-wider ${
scene.type === 'intro' || scene.type === 'outro'
? 'bg-amber-500/10 text-amber-500'
: 'bg-neutral-800 text-neutral-500'
}`}
>
{scene.name}
</span>
))}
</div>
</div>
</button>
))}
</div>
{filtered.length === 0 && (
<div className="text-center py-16 text-neutral-600">
<Filter size={32} className="mx-auto mb-3 opacity-40" />
<p className="text-sm">No hay plantillas para estos filtros</p>
</div>
)}
</div>
);
};
+190
View File
@@ -0,0 +1,190 @@
import React, { useRef, useState, useCallback, RefObject } from 'react';
import { Play, Pause, RotateCcw, Volume2 } from 'lucide-react';
import { PlayerRef } from '@remotion/player';
import { TimelineElement } from '../../types';
import { TimelineRuler } from '../timeline/TimelineRuler';
import { TimelinePlayhead } from '../timeline/TimelinePlayhead';
interface ExpressTimelineProps {
playerRef: RefObject<PlayerRef | null>;
elements: TimelineElement[];
durationInFrames: number;
selectedSlotId: string | null;
onSelectSlot: (slotId: string) => void;
isPlaying: boolean;
onPlayToggle: () => void;
onSeek: (frame: number) => void;
duration: number;
}
/** Color + icon mapping for each element type */
const SLOT_COLORS: Record<string, { bg: string; border: string; text: string }> = {
text: { bg: 'bg-violet-500/20', border: 'border-violet-500/40', text: 'text-violet-300' },
image: { bg: 'bg-sky-500/20', border: 'border-sky-500/40', text: 'text-sky-300' },
video: { bg: 'bg-sky-500/20', border: 'border-sky-500/40', text: 'text-sky-300' },
audio: { bg: 'bg-emerald-500/20', border: 'border-emerald-500/40', text: 'text-emerald-300' },
sticker: { bg: 'bg-amber-500/20', border: 'border-amber-500/40', text: 'text-amber-300' },
shape: { bg: 'bg-fuchsia-500/20', border: 'border-fuchsia-500/40', text: 'text-fuchsia-300' },
};
const SLOT_SELECTED: Record<string, { bg: string; border: string }> = {
text: { bg: 'bg-violet-500/35', border: 'border-violet-500/70' },
image: { bg: 'bg-sky-500/35', border: 'border-sky-500/70' },
video: { bg: 'bg-sky-500/35', border: 'border-sky-500/70' },
audio: { bg: 'bg-emerald-500/35', border: 'border-emerald-500/70' },
sticker: { bg: 'bg-amber-500/35', border: 'border-amber-500/70' },
shape: { bg: 'bg-fuchsia-500/35', border: 'border-fuchsia-500/70' },
};
/**
* ExpressTimeline Simplified timeline for Express editor.
* Reuses TimelineRuler and TimelinePlayhead from the Pro editor.
* Shows element bars as simple colored blocks, no resize/reorder.
*/
export const ExpressTimeline: React.FC<ExpressTimelineProps> = ({
playerRef,
elements,
durationInFrames,
selectedSlotId,
onSelectSlot,
isPlaying,
onPlayToggle,
onSeek,
duration,
}) => {
const timelineRef = useRef<HTMLDivElement>(null);
const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false);
const seekFromPointer = useCallback((clientX: number) => {
if (!timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
const ratio = x / rect.width;
const frame = Math.round(ratio * durationInFrames);
onSeek(frame);
}, [durationInFrames, onSeek]);
const handleRulerPointerDown = useCallback((e: React.PointerEvent) => {
seekFromPointer(e.clientX);
}, [seekFromPointer]);
const handleRulerPointerMove = useCallback((e: React.PointerEvent) => {
if (e.buttons === 1) seekFromPointer(e.clientX);
}, [seekFromPointer]);
const handlePlayheadPointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
setIsDraggingPlayhead(true);
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
}, []);
const handlePlayheadPointerMove = useCallback((e: React.PointerEvent) => {
if (!isDraggingPlayhead) return;
seekFromPointer(e.clientX);
}, [isDraggingPlayhead, seekFromPointer]);
const handlePlayheadPointerUp = useCallback(() => {
setIsDraggingPlayhead(false);
}, []);
// Filter out brand elements (intro/outro) from display — they show as amber accent
const contentElements = elements.filter(el => !el.isBrandElement);
const brandElements = elements.filter(el => el.isBrandElement);
return (
<div className="bg-neutral-900/80 border-t border-neutral-800/60 shrink-0">
{/* ── Mini Controls ── */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-neutral-800/40">
<button
onClick={onPlayToggle}
title={isPlaying ? 'Pausar' : 'Reproducir'}
className="w-6 h-6 rounded-full bg-violet-600 hover:bg-violet-500 text-white flex items-center justify-center transition-colors shadow-sm"
>
{isPlaying ? <Pause size={10} fill="currentColor" /> : <Play size={10} fill="currentColor" />}
</button>
<button
onClick={() => onSeek(0)}
title="Reiniciar"
className="w-5 h-5 rounded-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 flex items-center justify-center transition-colors"
>
<RotateCcw size={9} />
</button>
<span className="text-[9px] text-neutral-500 font-mono">{duration}s · {durationInFrames}f</span>
<div className="flex-1" />
{elements.some(el => el.type === 'audio') && (
<Volume2 size={12} className="text-emerald-400 opacity-60" />
)}
</div>
{/* ── Ruler + Tracks ── */}
<div ref={timelineRef} className="relative">
{/* Ruler (REUSED) */}
<TimelineRuler
timeUnit="seconds"
durationInFrames={durationInFrames}
onPointerDown={handleRulerPointerDown}
onPointerMove={handleRulerPointerMove}
onPointerUp={() => {}}
/>
{/* Track bars */}
<div className="relative px-1 py-1 space-y-0.5 min-h-[40px]">
{/* Brand elements (intro/outro) — subtle amber */}
{brandElements.map(el => {
const left = (el.startFrame / durationInFrames) * 100;
const width = ((el.endFrame - el.startFrame) / durationInFrames) * 100;
return (
<div
key={el.id}
className="h-5 rounded-md bg-amber-500/10 border border-amber-500/20 flex items-center px-1.5 overflow-hidden"
style={{ marginLeft: `${left}%`, width: `${width}%` }}
title={el.startFrame === 0 ? 'Intro de marca' : 'Outro de marca'}
>
<span className="text-[7px] text-amber-400 font-medium truncate">
{el.startFrame === 0 ? '🎬 Intro' : '🎬 Outro'}
</span>
</div>
);
})}
{/* Content elements — colored by type */}
{contentElements.map(el => {
const left = (el.startFrame / durationInFrames) * 100;
const width = Math.max(2, ((el.endFrame - el.startFrame) / durationInFrames) * 100);
const colors = SLOT_COLORS[el.type] || SLOT_COLORS.text;
const isSelected = selectedSlotId === el.id;
const selectedColors = SLOT_SELECTED[el.type] || SLOT_SELECTED.text;
return (
<button
key={el.id}
onClick={() => onSelectSlot(el.id)}
title={el.elementName || el.content?.substring(0, 30) || el.type}
className={`h-5 rounded-md border flex items-center px-1.5 overflow-hidden cursor-pointer transition-all hover:brightness-125 ${
isSelected
? `${selectedColors.bg} ${selectedColors.border} ring-1 ring-white/10`
: `${colors.bg} ${colors.border}`
}`}
style={{ marginLeft: `${left}%`, width: `${width}%` }}
>
<span className={`text-[7px] font-medium truncate ${colors.text}`}>
{el.elementName || el.content?.substring(0, 25) || el.type}
</span>
</button>
);
})}
</div>
{/* Playhead (REUSED) */}
<TimelinePlayhead
playerRef={playerRef}
durationInFrames={durationInFrames}
onPointerDown={handlePlayheadPointerDown}
onPointerMove={handlePlayheadPointerMove}
onPointerUp={handlePlayheadPointerUp}
isDraggingPlayhead={isDraggingPlayhead}
/>
</div>
</div>
);
};
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
import { Film, Image as ImageIcon, Play, Type, Camera, ChevronRight } from 'lucide-react';
import { ExpressScene } from '../../types';
interface SceneCardProps {
scene: ExpressScene;
index: number;
isActive: boolean;
onClick: () => void;
/** Whether any field has user content */
hasContent: boolean;
/** Total scenes count */
totalScenes: number;
}
/** Type badge styles */
const TYPE_STYLES: Record<string, { bg: string; border: string; icon: React.ReactNode; label: string }> = {
intro: { bg: 'bg-amber-500/15', border: 'border-amber-500/40', icon: <Film size={10} />, label: 'INTRO' },
content: { bg: 'bg-violet-500/15', border: 'border-violet-500/40', icon: <Camera size={10} />, label: 'CONTENIDO' },
outro: { bg: 'bg-amber-500/15', border: 'border-amber-500/40', icon: <Film size={10} />, label: 'OUTRO' },
transition: { bg: 'bg-sky-500/15', border: 'border-sky-500/40', icon: <Play size={10} />, label: 'TRANSICIÓN' },
};
/**
* SceneCard Visual card representing a single scene in the storyboard.
* Shows scene type, name, duration, and field summary.
*/
export const SceneCard: React.FC<SceneCardProps> = ({
scene,
index,
isActive,
onClick,
hasContent,
totalScenes,
}) => {
const typeStyle = TYPE_STYLES[scene.type] || TYPE_STYLES.content;
const textFields = scene.editableFields.filter(f => f.type === 'text');
const mediaFields = scene.editableFields.filter(f => f.type === 'media');
const logoFields = scene.editableFields.filter(f => f.type === 'logo');
return (
<div className="flex items-center shrink-0">
<button
onClick={onClick}
title={`${scene.name}${scene.durationSeconds}s`}
className={`relative w-28 h-24 rounded-xl border-2 transition-all overflow-hidden cursor-pointer group shrink-0 ${
isActive
? `${typeStyle.bg} ${typeStyle.border} ring-2 ring-white/10 shadow-lg`
: `bg-neutral-900 border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800/50`
}`}
>
{/* Type badge */}
<div className={`absolute top-1.5 left-1.5 flex items-center gap-0.5 px-1 py-0.5 rounded text-[7px] font-bold tracking-wider ${
isActive ? 'text-white bg-black/30' : 'text-neutral-500 bg-neutral-800'
}`}>
{typeStyle.icon}
{typeStyle.label}
</div>
{/* Duration */}
<div className="absolute top-1.5 right-1.5 text-[8px] font-mono text-neutral-500">
{scene.durationSeconds}s
</div>
{/* Scene name */}
<div className="absolute bottom-1.5 left-1.5 right-1.5">
<div className={`text-[10px] font-semibold truncate ${isActive ? 'text-white' : 'text-neutral-400'}`}>
{scene.name}
</div>
{/* Field summary */}
<div className="flex items-center gap-1.5 mt-0.5">
{textFields.length > 0 && (
<span className="flex items-center gap-0.5 text-[7px] text-neutral-500">
<Type size={7} /> {textFields.length}
</span>
)}
{mediaFields.length > 0 && (
<span className="flex items-center gap-0.5 text-[7px] text-neutral-500">
<ImageIcon size={7} /> {mediaFields.length}
</span>
)}
{logoFields.length > 0 && (
<span className="flex items-center gap-0.5 text-[7px] text-neutral-500">
{logoFields.length}
</span>
)}
</div>
</div>
{/* Content indicator */}
{hasContent && (
<div className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-emerald-400" />
)}
{/* Scene number */}
<div className={`absolute inset-0 flex items-center justify-center text-3xl font-black transition-opacity ${
isActive ? 'opacity-10 text-white' : 'opacity-5 text-neutral-400'
}`}>
{index + 1}
</div>
</button>
{/* Arrow connector (except last scene) */}
{index < totalScenes - 1 && (
<ChevronRight size={14} className="text-neutral-700 mx-1 shrink-0" />
)}
</div>
);
};
+220
View File
@@ -0,0 +1,220 @@
import React from 'react';
import { Type, Image as ImageIcon, Upload, Zap, Clock, Layers } from 'lucide-react';
import { ExpressScene, DesignMD, SceneLayout, TemplateField } from '../../types';
interface SceneFieldEditorProps {
scene: ExpressScene;
fieldData: Record<string, string>;
onFieldChange: (fieldId: string, value: string) => void;
designMD: DesignMD;
}
/** Layout display names */
const LAYOUT_LABELS: Record<SceneLayout, string> = {
'fullscreen-media': '📸 Pantalla completa',
'media-left': '◧ Media izquierda',
'media-right': '◨ Media derecha',
'text-only': '📝 Solo texto',
'split': '◫ Dividido',
'overlay': '🔲 Overlay',
};
/**
* SceneFieldEditor Right panel showing editable fields for the active scene.
* User fills in text and media no video editor needed.
*
* Supports both new TemplateField[] format (scene.fields) and legacy ExpressField[] (scene.editableFields).
* When using new format, only shows editable-slot fields sorted by formOrder.
*/
export const SceneFieldEditor: React.FC<SceneFieldEditorProps> = ({
scene,
fieldData,
onFieldChange,
designMD,
}) => {
// Prefer new TemplateField[] format; filter to only editable-slots, sort by formOrder
const useNewFormat = scene.fields && scene.fields.length > 0;
let textFields: Array<{ id: string; label: string; required: boolean; brandSource?: string; placeholder: string; style: { fontSize?: number; fontWeight?: number } }>;
let mediaFields: Array<{ id: string; label: string; required: boolean; placeholder: string; rules?: TemplateField['rules'] }>;
let logoFields: Array<{ id: string; label: string; brandSource?: string }>;
if (useNewFormat) {
const editableSlots = scene.fields!
.filter(f => f.nature === 'editable-slot')
.sort((a, b) => a.formOrder - b.formOrder);
textFields = editableSlots
.filter(f => f.type === 'text')
.map(f => ({ id: f.id, label: f.label, required: f.required, placeholder: f.content || f.label, style: f.style }));
mediaFields = editableSlots
.filter(f => f.type === 'image' || f.type === 'video')
.map(f => ({ id: f.id, label: f.label, required: f.required, placeholder: f.content || f.label, rules: f.rules }));
// Brand variables shown as read-only logo/info fields
logoFields = scene.fields!
.filter(f => f.nature === 'brand-variable')
.map(f => ({ id: f.id, label: f.label, brandSource: f.brandSource }));
} else {
textFields = scene.editableFields
.filter(f => f.type === 'text')
.map(f => ({ id: f.id, label: f.label, required: f.required, brandSource: f.brandSource, placeholder: f.placeholder || f.label, style: f.style }));
mediaFields = scene.editableFields
.filter(f => f.type === 'media')
.map(f => ({ id: f.id, label: f.label, required: f.required, placeholder: f.placeholder || f.label }));
logoFields = scene.editableFields
.filter(f => f.type === 'logo')
.map(f => ({ id: f.id, label: f.label, brandSource: f.brandSource }));
}
return (
<div className="space-y-4">
{/* Scene header */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
scene.type === 'intro' || scene.type === 'outro' ? 'bg-amber-500' : 'bg-violet-500'
}`} />
<span className="text-sm font-semibold text-white">{scene.name}</span>
</div>
<div className="flex items-center gap-3 text-[9px] text-neutral-500">
<span className="flex items-center gap-1">
<Clock size={9} /> {scene.durationSeconds}s
</span>
<span className="flex items-center gap-1">
<Layers size={9} /> {LAYOUT_LABELS[scene.layout]}
</span>
</div>
</div>
{/* Text fields */}
{textFields.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Type size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Textos</span>
</div>
{textFields.map(field => {
const isBrandVar = !!field.brandSource;
return (
<div key={field.id} className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[10px] text-neutral-400 font-medium">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{isBrandVar && (
<span className="flex items-center gap-0.5 text-[7px] text-violet-400 bg-violet-500/10 px-1 py-0.5 rounded">
<Zap size={7} /> auto
</span>
)}
</div>
{field.style.fontSize && field.style.fontSize >= 28 ? (
<input
type="text"
value={fieldData[field.id] || ''}
onChange={(e) => onFieldChange(field.id, e.target.value)}
placeholder={field.placeholder.replace(/\{[^}]+\}/g, designMD.brandName || '')}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-white placeholder-neutral-600 focus:border-violet-500/50 focus:outline-none transition-colors"
style={{
fontFamily: designMD.titleFont || designMD.baseFont,
fontWeight: field.style.fontWeight || 700,
}}
/>
) : (
<textarea
value={fieldData[field.id] || ''}
onChange={(e) => onFieldChange(field.id, e.target.value)}
placeholder={field.placeholder.replace(/\{[^}]+\}/g, designMD.brandName || '')}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-xs text-white placeholder-neutral-600 focus:border-violet-500/50 focus:outline-none transition-colors resize-none"
style={{
fontFamily: designMD.paragraphFont || designMD.baseFont,
}}
/>
)}
</div>
);
})}
</div>
)}
{/* Media fields */}
{mediaFields.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<ImageIcon size={10} className="text-neutral-500" />
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Media</span>
</div>
{mediaFields.map(field => (
<div key={field.id} className="space-y-1">
<label className="text-[10px] text-neutral-400 font-medium">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{fieldData[field.id] ? (
<div className="relative group">
<img
src={fieldData[field.id]}
alt={field.label}
className="w-full h-24 object-cover rounded-lg border border-neutral-700"
/>
<button
onClick={() => onFieldChange(field.id, '')}
title="Quitar media"
className="absolute top-1 right-1 bg-black/60 text-white text-[8px] px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center h-20 bg-neutral-800/50 border-2 border-dashed border-neutral-700 rounded-lg cursor-pointer hover:border-neutral-600 hover:bg-neutral-800 transition-all">
<Upload size={16} className="text-neutral-500 mb-1" />
<span className="text-[9px] text-neutral-500">{field.placeholder}</span>
<input
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
onFieldChange(field.id, url);
}
}}
/>
</label>
)}
</div>
))}
</div>
)}
{/* Logo fields (auto from brand) */}
{logoFields.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Zap size={10} className="text-violet-400" />
<span className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold">Marca</span>
</div>
{logoFields.map(field => (
<div key={field.id} className="flex items-center gap-2 bg-violet-500/5 border border-violet-500/20 rounded-lg px-3 py-2">
{designMD.logoUrl ? (
<img src={designMD.logoUrl} alt="Logo" className="w-8 h-8 object-contain" />
) : (
<div className="w-8 h-8 bg-neutral-800 rounded flex items-center justify-center text-[8px] text-neutral-500">Logo</div>
)}
<div>
<span className="text-[10px] text-violet-300">{field.label}</span>
<span className="text-[8px] text-violet-400/60 block">Auto desde tu marca</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
import { Clock } from 'lucide-react';
import { ExpressScene } from '../../types';
import { SceneCard } from './SceneCard';
interface StoryboardViewProps {
scenes: ExpressScene[];
activeSceneId: string | null;
onSelectScene: (sceneId: string) => void;
fieldData: Record<string, string>;
totalDuration: number;
}
/**
* StoryboardView Horizontal strip of scene cards.
* This IS the "timeline" for Express no video editor needed.
* User clicks a scene to edit its fields in the right panel.
*/
export const StoryboardView: React.FC<StoryboardViewProps> = ({
scenes,
activeSceneId,
onSelectScene,
fieldData,
totalDuration,
}) => {
return (
<div className="bg-neutral-900/80 border-t border-neutral-800/60 shrink-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-neutral-800/40">
<div className="flex items-center gap-2">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Escenas</span>
<span className="text-[9px] text-neutral-600 font-mono bg-neutral-800 px-1.5 py-0.5 rounded">
{scenes.length} escenas
</span>
</div>
<div className="flex items-center gap-1.5">
<Clock size={10} className="text-neutral-600" />
<span className="text-[9px] text-neutral-500 font-mono">{totalDuration}s total</span>
</div>
</div>
{/* Scene cards strip */}
<div className="flex items-center overflow-x-auto px-4 py-3 gap-0 scrollbar-thin scrollbar-track-neutral-900 scrollbar-thumb-neutral-700">
{scenes.map((scene, i) => {
const hasContent = scene.editableFields.some(
f => fieldData[f.id]?.trim()
);
return (
<SceneCard
key={scene.id}
scene={scene}
index={i}
isActive={activeSceneId === scene.id}
onClick={() => onSelectScene(scene.id)}
hasContent={hasContent}
totalScenes={scenes.length}
/>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,473 @@
import React, { useRef, useCallback, useMemo } from 'react';
import {
Type, Image as ImageIcon, Video, Pentagon, Zap, Move, Maximize2,
Upload, Film,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, DesignMD, CompanyProfile, StickerConfig } from '../../../types';
import { getAspectDimensions } from '../../../utils/expressCompiler';
import { useDragResize } from '../../../hooks/useDragResize';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { getPlatformIcon, isSocialSource, DEFAULT_STICKER } from './PlatformIcons';
import { resolveBrandRole } from '../../ui/FieldInspector';
import { SegmentVideoFrame } from './SegmentVideoFrame';
/** Get type icon */
function getTypeIcon(type: TemplateField['type'], size = 14): React.ReactNode {
switch (type) {
case 'text': return <Type size={size} />;
case 'image': return <ImageIcon size={size} />;
case 'video': return <Video size={size} />;
case 'shape': return <Pentagon size={size} />;
case 'sticker': return <Zap size={size} />;
}
}
/** Nature colors */
const NATURE_COLORS: Record<TemplateFieldNature, { border: string; bg: string; text: string; badge: string }> = {
'static': {
border: 'rgba(107, 114, 128, 0.4)',
bg: 'rgba(107, 114, 128, 0.08)',
text: '#9ca3af',
badge: '#6b7280',
},
'brand-variable': {
border: 'rgba(167, 139, 250, 0.5)',
bg: 'rgba(167, 139, 250, 0.08)',
text: '#c4b5fd',
badge: '#a78bfa',
},
'editable-slot': {
border: 'rgba(56, 189, 248, 0.5)',
bg: 'rgba(56, 189, 248, 0.06)',
text: '#7dd3fc',
badge: '#38bdf8',
},
};
/** Resolve brand variable to preview text */
function resolveBrandPreview(field: TemplateField, designMD: DesignMD, company: CompanyProfile): string {
if (field.nature !== 'brand-variable' || !field.brandSource) return field.content;
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || 'Tu Marca';
case 'tagline': return company.tagline || 'Tu eslogan';
case 'logo': return designMD.logoUrl || '';
case 'instagram': return company.socialLinks?.instagram || '@instagram';
case 'tiktok': return company.socialLinks?.tiktok || '@tiktok';
case 'twitter': return company.socialLinks?.x || '@x';
case 'youtube': return company.socialLinks?.youtube || 'YouTube';
case 'website': return company.socialLinks?.website || 'www.example.com';
default: return field.content;
}
}
/**
* BuilderCanvas Interactive canvas for the Template Builder.
*
* Renders TemplateField[] directly with visual differentiation by nature:
* - static: solid border, rendered content, no badge
* - brand-variable: dotted violet border, real preview data, "auto" badge
* - editable-slot: dashed blue border, placeholder zone with icon + label, "campo" badge
*
* Uses the shared `useDragResize` hook for all pointer interactions (per AGENTS.md).
*/
export const BuilderCanvas: React.FC = () => {
const {
fields,
updateField,
selectedFieldId,
setSelectedFieldId,
designMD,
company,
templateMeta,
activeScene,
updateSegment,
previewBrand,
} = useTemplateBuilder();
// Detect segment mode: active scene is an intro/outro with segmentSource
const isSegmentMode = !!(activeScene?.segmentSource);
const containerRef = useRef<HTMLDivElement>(null);
// ── Shared drag/resize hook ──
const {
startDrag,
startResize,
handlePointerMove,
handlePointerUp,
isDragging,
activeId: dragFieldId,
snapGuides,
} = useDragResize({
containerRef: containerRef as React.RefObject<HTMLElement>,
onMove: useCallback((id: string, x: number, y: number) => {
const field = fields.find(f => f.id === id);
if (!field) return;
updateField(id, { position: { ...field.position, x, y } });
}, [fields, updateField]),
onResize: useCallback((id: string, w: number, h: number) => {
const field = fields.find(f => f.id === id);
if (!field) return;
updateField(id, { position: { ...field.position, w, h } });
}, [fields, updateField]),
snapLines: [50],
snapThreshold: 1.5,
});
const dimensions = getAspectDimensions(templateMeta.aspectRatio);
// Resolve background
const bgColor = useMemo(() => {
const bg = activeScene?.background;
if (!bg) return designMD.secondaryColor;
switch (bg.type) {
case 'brand': return designMD.secondaryColor;
case 'solid': return bg.value || '#1a1a1a';
case 'gradient': return undefined;
case 'media': return '#111';
default: return designMD.secondaryColor;
}
}, [activeScene, designMD]);
const bgGradient = activeScene?.background?.type === 'gradient'
? `linear-gradient(135deg, ${designMD.primaryColor} 0%, ${designMD.secondaryColor} 100%)`
: undefined;
const handleCanvasClick = useCallback(() => {
if (!isDragging) setSelectedFieldId(null);
}, [isDragging, setSelectedFieldId]);
// In segment mode, render SegmentVideoFrame instead of normal fields
if (isSegmentMode && activeScene) {
return (
<SegmentVideoFrame
scene={activeScene}
designMD={designMD}
previewBrand={previewBrand}
aspectRatio={templateMeta.aspectRatio}
onPositionChange={(updates) => updateSegment(activeScene.id, updates)}
/>
);
}
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Dot pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Canvas wrapper */}
<div
ref={containerRef}
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 select-none shrink-0"
style={{
...(templateMeta.aspectRatio === '9:16' || templateMeta.aspectRatio === '4:5'
? { height: 'calc(100% - 40px)', maxWidth: '90%' }
: {
width: templateMeta.aspectRatio === '1:1' ? 360 : 440,
maxHeight: 'calc(100% - 40px)',
}),
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
backgroundColor: bgColor,
backgroundImage: bgGradient,
}}
onPointerDown={handleCanvasClick}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Snap guides */}
{snapGuides.x !== undefined && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
style={{ left: `${snapGuides.x}%`, width: '1px', background: 'rgba(139, 92, 246, 0.5)', borderLeft: '1px dashed rgba(139, 92, 246, 0.6)' }}
/>
)}
{snapGuides.y !== undefined && (
<div
className="absolute left-0 right-0 pointer-events-none z-50"
style={{ top: `${snapGuides.y}%`, height: '1px', background: 'rgba(139, 92, 246, 0.5)', borderTop: '1px dashed rgba(139, 92, 246, 0.6)' }}
/>
)}
{/* Center crosshair (subtle) */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-white/[0.03] pointer-events-none z-0" />
<div className="absolute top-1/2 left-0 right-0 h-px bg-white/[0.03] pointer-events-none z-0" />
{/* ── Render fields ── */}
{fields.map((field, idx) => {
// Skip hidden layers
if (field.visible === false) return null;
const isSelected = selectedFieldId === field.id;
const isDraggingField = dragFieldId === field.id;
const isLocked = field.locked === true;
const colors = NATURE_COLORS[field.nature];
return (
<div
key={field.id}
className="absolute transition-shadow"
style={{
left: `${field.position.x - field.position.w / 2}%`,
top: `${field.position.y - field.position.h / 2}%`,
width: `${field.position.w}%`,
height: `${field.position.h}%`,
transform: field.position.rotation ? `rotate(${field.position.rotation}deg)` : undefined,
// z-index from array position: index 0 = back, last = front
// Dragging/selected get temporary boost to stay on top during interaction
zIndex: isDraggingField ? 1000 : isSelected ? 999 : idx + 1,
}}
>
{/* Field box */}
<div
className={`w-full h-full rounded-md flex flex-col items-center justify-center gap-0.5 transition-all ${
isLocked ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'
} ${isDraggingField ? 'scale-[1.02] shadow-xl' : ''}`}
style={{
backgroundColor: isSelected ? `${colors.badge}20` : colors.bg,
border: `${field.nature === 'editable-slot' ? '2px dashed' : field.nature === 'brand-variable' ? '1px dotted' : '1px solid'} ${
isSelected ? colors.badge : colors.border
}`,
outline: isSelected ? `2px solid ${colors.badge}60` : undefined,
outlineOffset: isSelected ? '2px' : undefined,
}}
onPointerDown={(e) => {
e.stopPropagation();
if (isLocked) return; // Can't interact with locked layers
setSelectedFieldId(field.id);
startDrag(e, field.id, field.position);
}}
>
{/* ── Nature-specific content ── */}
{field.nature === 'static' && (
<StaticFieldContent field={field} designMD={designMD} />
)}
{field.nature === 'brand-variable' && (
<BrandVariableContent field={field} designMD={designMD} company={company} />
)}
{field.nature === 'editable-slot' && (
<EditableSlotContent field={field} />
)}
{/* ── Badge (brand-variable and editable-slot) ── */}
{field.nature !== 'static' && (
<div
className="absolute -top-2.5 left-2 flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[7px] font-bold tracking-wider pointer-events-none"
style={{
backgroundColor: `${colors.badge}20`,
color: colors.badge,
border: `1px solid ${colors.badge}40`,
}}
>
{field.nature === 'brand-variable' ? (
<><Zap size={7} /> auto</>
) : (
<>{getTypeIcon(field.type, 7)} {field.label}</>
)}
</div>
)}
{/* Position readout when selected */}
{isSelected && (
<div className="absolute -bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1 text-[7px] text-violet-300/60 font-mono whitespace-nowrap pointer-events-none">
<Move size={7} /> {field.position.x.toFixed(0)},{field.position.y.toFixed(0)}
<Maximize2 size={7} className="ml-1" /> {field.position.w.toFixed(0)}×{field.position.h.toFixed(0)}
</div>
)}
</div>
{/* Resize handle */}
{isSelected && (
<div
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 border-2 border-neutral-900 rounded-sm cursor-nwse-resize z-40 hover:opacity-80 transition-colors"
style={{ backgroundColor: colors.badge }}
onPointerDown={(e) => startResize(e, field.id, field.position)}
title="Redimensionar campo"
/>
)}
</div>
);
})}
{/* Empty state */}
{fields.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-[10px] text-white/20 text-center">
Agrega campos desde el panel izquierdo<br />
para posicionarlos aquí
</p>
</div>
)}
</div>
</div>
);
};
// ═══ Nature-specific content renderers ═══
/** Static: show actual content/shape */
const StaticFieldContent: React.FC<{ field: TemplateField; designMD: DesignMD }> = ({ field, designMD }) => {
if (field.type === 'text') {
// Resolve brand typography if useBrandStyle is active
const brandStyle = (field.style.useBrandStyle !== false && field.style.textRole)
? resolveBrandRole(designMD, field.style.textRole)
: null;
return (
<span
className="pointer-events-none text-center px-1 truncate w-full"
style={{
fontSize: `${Math.min(brandStyle?.fontSize || field.style.fontSize || 16, 20)}px`,
fontWeight: brandStyle?.fontWeight || field.style.fontWeight || 400,
fontFamily: brandStyle?.fontFamily || field.style.fontFamily || designMD.baseFont,
color: brandStyle?.color || field.style.color || designMD.textColor || '#ffffff',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{field.content || 'Texto estático'}
</span>
);
}
if (field.type === 'shape') {
return (
<div
className="w-full h-full rounded pointer-events-none"
style={{
backgroundColor: field.style.shapeFill || designMD.primaryColor,
borderRadius: field.style.shapeCornerRadius ? `${field.style.shapeCornerRadius}px` : undefined,
opacity: (field.style.opacity ?? 100) / 100,
}}
/>
);
}
// image or video static
return (
<div className="flex flex-col items-center justify-center gap-0.5 pointer-events-none" style={{ color: '#6b7280' }}>
{getTypeIcon(field.type, 16)}
<span className="text-[7px] font-mono">{field.type}</span>
</div>
);
};
/** Brand variable: show real preview with brand styling */
const BrandVariableContent: React.FC<{ field: TemplateField; designMD: DesignMD; company: CompanyProfile }> = ({ field, designMD, company }) => {
const preview = resolveBrandPreview(field, designMD, company);
// Logo: show image
if (field.brandSource === 'logo' && designMD.logoUrl) {
return (
<img
src={designMD.logoUrl}
alt="Logo"
className="max-w-full max-h-full object-contain pointer-events-none p-1"
style={{ opacity: (field.style.opacity ?? 100) / 100 }}
/>
);
}
// Sticker: icon + text composite
if (field.type === 'sticker') {
return <BrandStickerContent field={field} designMD={designMD} company={company} />;
}
// Text brand variable: show with brand font
const brandStyle = (field.style.useBrandStyle !== false && field.style.textRole)
? resolveBrandRole(designMD, field.style.textRole)
: null;
return (
<span
className="pointer-events-none text-center px-1 truncate w-full"
style={{
fontSize: `${Math.min(brandStyle?.fontSize || field.style.fontSize || 16, 18)}px`,
fontWeight: brandStyle?.fontWeight || field.style.fontWeight || 400,
fontFamily: brandStyle?.fontFamily || field.style.fontFamily || designMD.baseFont,
color: brandStyle?.color || field.style.color || '#c4b5fd',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{preview}
</span>
);
};
/** Brand Sticker: icon + text as a single visual unit */
const BrandStickerContent: React.FC<{ field: TemplateField; designMD: DesignMD; company: CompanyProfile }> = ({ field, designMD, company }) => {
const sticker: StickerConfig = field.style.sticker || DEFAULT_STICKER;
const rawValue = resolveBrandPreview(field, designMD, company);
// Format display text
const displayText = sticker.showAtPrefix && isSocialSource(field.brandSource) && field.brandSource !== 'website'
? `@${rawValue.replace(/^@/, '')}`
: field.brandSource === 'website'
? rawValue.replace(/^https?:\/\//, '').replace(/\/$/, '')
: rawValue;
const iconSize = Math.min(Math.max((field.style.fontSize || 14) * 0.9, 10), 18);
const icon = sticker.showIcon ? getPlatformIcon(field.brandSource, iconSize) : null;
const isPill = sticker.stickerStyle === 'pill';
return (
<div
className={`flex items-center pointer-events-none w-full h-full justify-center ${
isPill ? 'px-2' : 'px-1'
}`}
>
<div
className={`flex items-center ${
isPill ? 'bg-white/10 rounded-full px-3 py-1' : ''
}`}
style={{
gap: `${sticker.gap}px`,
flexDirection: sticker.iconPosition === 'right' ? 'row-reverse' : 'row',
}}
>
{icon && (
<span
className="shrink-0 flex items-center justify-center"
style={{ color: sticker.iconColor || designMD.primaryColor }}
>
{icon}
</span>
)}
<span
className="truncate"
style={{
fontSize: `${Math.min(field.style.fontSize || 14, 16)}px`,
fontWeight: field.style.fontWeight || 500,
fontFamily: field.style.fontFamily || designMD.baseFont,
color: field.style.color || '#c4b5fd',
opacity: (field.style.opacity ?? 100) / 100,
}}
>
{displayText}
</span>
</div>
</div>
);
};
/** Editable slot: show placeholder zone */
const EditableSlotContent: React.FC<{ field: TemplateField }> = ({ field }) => {
return (
<div className="flex flex-col items-center justify-center gap-1 pointer-events-none w-full h-full">
<div style={{ color: '#7dd3fc' }}>
{field.type === 'text' && <Type size={16} />}
{field.type === 'image' && <Upload size={16} />}
{field.type === 'video' && <Film size={16} />}
{field.type === 'shape' && <Pentagon size={16} />}
</div>
<span className="text-[8px] text-sky-300/60 font-medium truncate max-w-[90%] text-center">
{field.label}
</span>
{field.required && (
<span className="text-[6px] text-red-400/60 font-bold">OBLIGATORIO</span>
)}
</div>
);
};
@@ -0,0 +1,321 @@
import React from 'react';
import { X, Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD, TimelineElement } from '../../../types';
import { useEditor } from '../../../context/EditorContext';
import { CollapsibleSection } from '../../ui/CollapsibleSection';
interface BuilderScenePanelProps {
onClose: () => void;
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
}
/** Layout options */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: ExpressField['type'] }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* BuilderScenePanel Sliding panel for scene-specific configuration.
*
* This is the template-builder counterpart of TextPanel/ShapesPanel.
* It manages scene metadata (name, type, duration, background, layout)
* and lets users add brand variables and brand assets as TimelineElements.
*/
export const BuilderScenePanel: React.FC<BuilderScenePanelProps> = ({
onClose,
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
}) => {
const {
setTimelineElements,
setSelectedElementId,
layers,
activeLayerId,
durationInFrames,
} = useEditor();
// ── Add a brand-variable element to the canvas via EditorContext ──
const addBrandField = (
type: ExpressField['type'],
label: string,
brandSource?: ExpressField['brandSource'],
) => {
const newId = 'el-' + Date.now();
const elType: TimelineElement['type'] = type === 'text' ? 'text' : 'image';
// Determine target layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: brandSource ? `{${brandSource}}` : label,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: type === 'text' ? 80 : 40,
height: type === 'text' ? 10 : 20,
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
elementName: label,
notes: JSON.stringify({
__expressField: true,
brandSource,
required: false,
fieldType: type,
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
// ── Add a brand content asset ──
const addBrandAsset = (asset: BrandContentPiece) => {
const newId = 'el-asset-' + Date.now();
const elType: TimelineElement['type'] = asset.type === 'custom-image' ? 'image' : 'text';
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
const visual = layers.find(l => l.type === 'visual' || l.type == null);
if (visual) targetLayerId = visual.id;
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: elType,
content: asset.content.text || asset.name,
startFrame: 0,
endFrame: durationInFrames,
x: 50,
y: 50,
width: 40,
height: 20,
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
elementName: asset.name,
notes: JSON.stringify({
__expressField: true,
brandAssetId: asset.id,
required: false,
fieldType: asset.type === 'custom-image' ? 'media' : 'text',
}),
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
};
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Layers size={14} className="text-amber-400" />
Escena
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4 custom-scrollbar">
{/* Scene name */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Quick-add field buttons */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">
Agregar campos
</label>
<div className="flex gap-1">
<button
onClick={() => addBrandField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addBrandField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addBrandField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* ── Diseño y Fondo (collapsible) ── */}
<CollapsibleSection title="Diseño y Fondo">
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-2 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.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 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨' : bg === 'solid' ? '⬛' : bg === 'gradient' ? '🌈' : '📷'}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
)}
</CollapsibleSection>
</div>
</div>
);
};
@@ -0,0 +1,475 @@
import React from 'react';
import {
Settings2, Tag, ToggleLeft, Type, Image as ImageIcon, Video, Pentagon,
Zap, AlertCircle, Hash, Eye, EyeOff, ArrowLeftRight,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, TemplateFieldType, BrandSource, StickerConfig } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { FieldInspector } from '../../ui/FieldInspector';
import { CollapsibleSection } from '../../ui/CollapsibleSection';
import { DEFAULT_STICKER, getPlatformIcon } from './PlatformIcons';
/** Nature display config */
const NATURE_CONFIG: Record<TemplateFieldNature, { label: string; color: string; icon: React.ReactNode }> = {
'static': { label: 'Estático', color: '#6b7280', icon: <Pentagon size={10} /> },
'brand-variable': { label: 'Variable de marca', color: '#a78bfa', icon: <Zap size={10} /> },
'editable-slot': { label: 'Campo editable', color: '#38bdf8', icon: <Tag size={10} /> },
};
/** Type options */
const TYPE_OPTIONS: { value: TemplateFieldType; label: string; icon: React.ReactNode }[] = [
{ value: 'text', label: 'Texto', icon: <Type size={10} /> },
{ value: 'image', label: 'Imagen', icon: <ImageIcon size={10} /> },
{ value: 'video', label: 'Video', icon: <Video size={10} /> },
{ value: 'shape', label: 'Forma', icon: <Pentagon size={10} /> },
{ value: 'sticker', label: 'Sticker', icon: <Zap size={10} /> },
];
/** Brand sources */
const BRAND_SOURCES: { value: BrandSource; label: string }[] = [
{ value: 'brand-name', label: 'Nombre de Marca' },
{ value: 'tagline', label: 'Tagline' },
{ value: 'logo', label: 'Logo' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'tiktok', label: 'TikTok' },
{ value: 'twitter', label: 'X / Twitter' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'website', label: 'Website' },
];
/**
* FieldConfigPanel Right panel in the Template Builder.
*
* Shows properties for the selected field, adapted by its nature.
* Reuses FieldInspector for position editing and CollapsibleSection for grouping.
*/
export const FieldConfigPanel: React.FC = () => {
const {
fields,
selectedFieldId,
setSelectedFieldId,
updateField,
resolvedDesignMD,
editableSlotCount,
totalFieldCount,
templateMeta,
} = useTemplateBuilder();
const field = fields.find(f => f.id === selectedFieldId);
// No selection — show hint
if (!field) {
return (
<div className="flex flex-col items-center justify-center h-full px-6 text-center py-8">
<p className="text-[11px] text-neutral-500 leading-relaxed">
Selecciona un campo en el canvas o en la lista para configurarlo.
</p>
</div>
);
}
const natureConfig = NATURE_CONFIG[field.nature];
const brandColors = [resolvedDesignMD.primaryColor, resolvedDesignMD.secondaryColor, resolvedDesignMD.textColor].filter(Boolean);
return (
<div>
{/* Header */}
<div className="p-3 border-b border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-neutral-400" />
<span className="text-sm font-semibold text-white truncate max-w-[140px]">{field.label}</span>
</div>
<button
onClick={() => setSelectedFieldId(null)}
title="Deseleccionar"
className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors text-xs"
>
</button>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
style={{ color: natureConfig.color, backgroundColor: `${natureConfig.color}15`, border: `1px solid ${natureConfig.color}30` }}
>
{natureConfig.icon} {natureConfig.label}
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4 custom-scrollbar">
{/* ── Label ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Etiqueta</label>
<input
type="text"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })}
placeholder="Nombre del campo"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white placeholder-neutral-600 focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* ── Nature selector ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Naturaleza</label>
<div className="flex gap-1">
{(['static', 'brand-variable', 'editable-slot'] as TemplateFieldNature[]).map(nature => {
const cfg = NATURE_CONFIG[nature];
const isActive = field.nature === nature;
return (
<button
key={nature}
onClick={() => updateField(field.id, { nature })}
title={cfg.label}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg text-[8px] font-medium transition-all border ${
isActive
? 'text-white'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
style={isActive ? {
backgroundColor: `${cfg.color}15`,
borderColor: `${cfg.color}40`,
color: cfg.color,
} : {}}
>
{cfg.icon} {nature === 'editable-slot' ? 'Campo' : nature === 'brand-variable' ? 'Auto' : 'Fijo'}
</button>
);
})}
</div>
</div>
{/* ── Type selector ── */}
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<div className="flex gap-1">
{TYPE_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => updateField(field.id, { type: opt.value })}
title={opt.label}
className={`flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
field.type === opt.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'
}`}
>
{opt.icon} {opt.label}
</button>
))}
</div>
</div>
{/* ── Required toggle (editable-slot only) ── */}
{field.nature === 'editable-slot' && (
<div className="flex items-center justify-between">
<label className="text-[10px] text-neutral-400 flex items-center gap-1.5">
<AlertCircle size={10} />
Obligatorio
</label>
<button
onClick={() => updateField(field.id, { required: !field.required })}
title={field.required ? 'Marcar como opcional' : 'Marcar como obligatorio'}
className={`flex items-center gap-1 px-2 py-1 rounded text-[9px] font-medium transition-all ${
field.required
? 'bg-red-500/15 text-red-400 border border-red-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700 hover:text-neutral-300'
}`}
>
<ToggleLeft size={10} />
{field.required ? 'Sí' : 'No'}
</button>
</div>
)}
{/* ── Brand source (brand-variable only) ── */}
{field.nature === 'brand-variable' && (
<div className="space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Fuente de datos
</label>
<select
value={field.brandSource || ''}
onChange={(e) => updateField(field.id, { brandSource: e.target.value as BrandSource })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="">Seleccionar...</option>
{BRAND_SOURCES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
)}
{/* ── Sticker config (sticker type only) ── */}
{field.type === 'sticker' && (() => {
const sticker: StickerConfig = field.style.sticker || DEFAULT_STICKER;
const updateSticker = (patch: Partial<StickerConfig>) => {
updateField(field.id, {
style: { ...field.style, sticker: { ...sticker, ...patch } },
});
};
return (
<CollapsibleSection title="Sticker" badge={1} defaultOpen={true}>
<div className="space-y-3">
{/* Show icon */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400 flex items-center gap-1">
{sticker.showIcon ? <Eye size={10} /> : <EyeOff size={10} />}
Mostrar ícono
</label>
<button
onClick={() => updateSticker({ showIcon: !sticker.showIcon })}
title={sticker.showIcon ? 'Ocultar ícono' : 'Mostrar ícono'}
className={`flex items-center gap-1 px-2 py-1 rounded text-[9px] font-medium transition-all ${
sticker.showIcon
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{sticker.showIcon ? 'Sí' : 'No'}
</button>
</div>
{/* Icon position */}
{sticker.showIcon && (
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400 flex items-center gap-1">
<ArrowLeftRight size={10} />
Posición ícono
</label>
<div className="flex gap-1">
{(['left', 'right'] as const).map(pos => (
<button
key={pos}
onClick={() => updateSticker({ iconPosition: pos })}
title={pos === 'left' ? 'Ícono a la izquierda' : 'Ícono a la derecha'}
className={`px-2 py-1 rounded text-[8px] font-medium transition-all border ${
sticker.iconPosition === pos
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{pos === 'left' ? '← Izq' : 'Der →'}
</button>
))}
</div>
</div>
)}
{/* @ prefix */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Prefijo @</label>
<button
onClick={() => updateSticker({ showAtPrefix: !sticker.showAtPrefix })}
title={sticker.showAtPrefix ? 'Ocultar @' : 'Mostrar @'}
className={`px-2 py-1 rounded text-[9px] font-medium transition-all ${
sticker.showAtPrefix
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{sticker.showAtPrefix ? '@usuario' : 'usuario'}
</button>
</div>
{/* Style: plain or pill */}
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Estilo</label>
<div className="flex gap-1">
{(['plain', 'pill'] as const).map(style => (
<button
key={style}
onClick={() => updateSticker({ stickerStyle: style })}
title={style === 'plain' ? 'Texto plano' : 'Pill con fondo'}
className={`px-2 py-1 rounded text-[8px] font-medium transition-all border ${
sticker.stickerStyle === style
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{style === 'plain' ? 'Plano' : 'Pill'}
</button>
))}
</div>
</div>
{/* Gap */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Gap (px)</label>
<span className="text-[9px] text-neutral-500 font-mono">{sticker.gap}px</span>
</div>
<input
type="range"
min={0}
max={16}
value={sticker.gap}
onChange={(e) => updateSticker({ gap: parseInt(e.target.value) })}
className="w-full accent-violet-500 h-1"
/>
</div>
{/* Icon color */}
{sticker.showIcon && (
<div className="space-y-1">
<label className="text-[9px] text-neutral-400">Color ícono</label>
<div className="flex items-center gap-2">
<input
type="color"
value={sticker.iconColor || resolvedDesignMD.primaryColor}
onChange={(e) => updateSticker({ iconColor: e.target.value })}
className="w-6 h-6 rounded border border-neutral-700 cursor-pointer bg-transparent"
/>
<span className="text-[9px] text-neutral-500 font-mono">
{sticker.iconColor || resolvedDesignMD.primaryColor}
</span>
{sticker.iconColor && (
<button
onClick={() => updateSticker({ iconColor: undefined })}
title="Usar color de marca"
className="text-[8px] text-neutral-500 hover:text-neutral-300 transition-colors"
>
Reset
</button>
)}
</div>
{/* Preview */}
<div className="flex items-center gap-2 mt-1 px-2 py-1.5 bg-neutral-800/60 rounded-lg border border-neutral-700/50">
<span style={{ color: sticker.iconColor || resolvedDesignMD.primaryColor }}>
{getPlatformIcon(field.brandSource, 14)}
</span>
<span className="text-[10px] text-neutral-300">Vista previa</span>
</div>
</div>
)}
</div>
</CollapsibleSection>
);
})()}
<hr className="border-neutral-800/50" />
{/* ── Position (FieldInspector) ── */}
<FieldInspector
position={field.position}
onPositionChange={(pos) => {
updateField(field.id, {
position: { ...field.position, ...pos },
});
}}
textStyle={field.type === 'text' ? {
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
fontFamily: field.style.fontFamily,
color: field.style.color,
textAlign: field.style.textAlign,
opacity: field.style.opacity,
useBrandStyle: field.style.useBrandStyle,
textRole: field.style.textRole,
} : undefined}
onTextStyleChange={field.type === 'text' ? (style) => {
updateField(field.id, {
style: { ...field.style, ...style },
});
} : undefined}
fieldType={field.type === 'video' ? 'media' : field.type === 'shape' ? 'text' : field.type}
fieldLabel={field.label}
brandFont={resolvedDesignMD.baseFont?.split(',')[0]?.replace(/"/g, '')}
brandColors={brandColors}
resolvedDesignMD={resolvedDesignMD}
/>
{/* ── Rules (editable-slot only) ── */}
{field.nature === 'editable-slot' && (
<CollapsibleSection title="Reglas de validación" defaultOpen={false}>
<div className="space-y-2">
{/* Text rules */}
{field.type === 'text' && (
<>
<div className="space-y-1">
<label className="text-[9px] text-neutral-500">Máx. caracteres</label>
<input
type="number"
min={0}
value={field.rules?.maxChars || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, maxChars: parseInt(e.target.value) || undefined },
})}
placeholder="Sin límite"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-400">Multilínea</label>
<button
onClick={() => updateField(field.id, {
rules: { ...field.rules, multiline: !field.rules?.multiline },
})}
title="Alternar multilínea"
className={`text-[9px] px-2 py-1 rounded transition-all ${
field.rules?.multiline
? 'bg-violet-500/15 text-violet-300 border border-violet-500/30'
: 'bg-neutral-800 text-neutral-500 border border-neutral-700'
}`}
>
{field.rules?.multiline ? 'Sí' : 'No'}
</button>
</div>
</>
)}
{/* Image/Video rules */}
{(field.type === 'image' || field.type === 'video') && (
<>
<div className="space-y-1">
<label className="text-[9px] text-neutral-500">Aspect ratio</label>
<input
type="text"
value={field.rules?.aspectRatio || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, aspectRatio: e.target.value || undefined },
})}
placeholder="ej. 16:9"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500">Ancho mín.</label>
<input
type="number"
min={0}
value={field.rules?.minWidth || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, minWidth: parseInt(e.target.value) || undefined },
})}
placeholder="px"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500">Alto mín.</label>
<input
type="number"
min={0}
value={field.rules?.minHeight || ''}
onChange={(e) => updateField(field.id, {
rules: { ...field.rules, minHeight: parseInt(e.target.value) || undefined },
})}
placeholder="px"
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-xs text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
</div>
</>
)}
</div>
</CollapsibleSection>
)}
</div>
</div>
);
};
@@ -0,0 +1,435 @@
import React, { useCallback, useState } from 'react';
import {
Plus, Type, Image as ImageIcon, Video, Pentagon, Zap,
Trash2, GripVertical, Sparkles, Globe, Instagram, AtSign, Star, Layers,
Eye, EyeOff, Lock, Unlock,
} from 'lucide-react';
import { TemplateField, TemplateFieldNature, BrandSource, StickerConfig } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { DEFAULT_STICKER } from './PlatformIcons';
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: BrandSource; label: string; icon: React.ReactNode; fieldType: 'text' | 'image' | 'sticker' }[] = [
{ source: 'brand-name', label: 'Nombre', icon: <Type size={10} />, fieldType: 'text' },
{ source: 'tagline', label: 'Tagline', icon: <Sparkles size={10} />, fieldType: 'text' },
{ source: 'logo', label: 'Logo', icon: <Star size={10} />, fieldType: 'image' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, fieldType: 'sticker' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, fieldType: 'sticker' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, fieldType: 'sticker' },
];
/** Type icon mapping */
function getTypeIcon(type: TemplateField['type'], size = 10): React.ReactNode {
switch (type) {
case 'text': return <Type size={size} />;
case 'image': return <ImageIcon size={size} />;
case 'video': return <Video size={size} />;
case 'shape': return <Pentagon size={size} />;
case 'sticker': return <Zap size={size} />;
}
}
/** Nature badge config */
const NATURE_BADGE: Record<TemplateFieldNature, { label: string; color: string; bg: string; border: string }> = {
'static': { label: 'fijo', color: '#9ca3af', bg: 'rgba(107,114,128,0.10)', border: 'rgba(107,114,128,0.25)' },
'brand-variable': { label: 'auto', color: '#c4b5fd', bg: 'rgba(167,139,250,0.10)', border: 'rgba(167,139,250,0.25)' },
'editable-slot': { label: 'campo', color: '#7dd3fc', bg: 'rgba(56,189,248,0.10)', border: 'rgba(56,189,248,0.25)' },
};
/**
* FieldSchemaPanel Layers panel (Photoshop/Figma style) for the Template Builder.
*
* Photoshop convention: first row = front (highest z-index), last row = back.
* Internally, the fields array is ordered bottom-to-top (index 0 = back, last = front).
* The panel renders the list REVERSED so the topmost layer appears first.
*
* Each layer row shows: visibility toggle, lock toggle, type icon, editable name,
* nature badge, and optional req badge.
*
* Bottom section: quick-add buttons for new fields and brand variables.
*/
export const FieldSchemaPanel: React.FC<{ onClose?: () => void }> = ({ onClose }) => {
const {
fields,
addField,
removeField,
reorderField,
moveField,
updateField,
selectedFieldId,
setSelectedFieldId,
editableSlotCount,
totalFieldCount,
} = useTemplateBuilder();
// Reversed for Photoshop convention: front layers on top
const layersReversed = [...fields].reverse();
// ── Drag & Drop state ──
const [dragId, setDragId] = useState<string | null>(null);
const [dropDisplayIdx, setDropDisplayIdx] = useState<number | null>(null);
const handleDragStart = useCallback((fieldId: string) => {
setDragId(fieldId);
}, []);
const handleDragOver = useCallback((displayIdx: number) => {
setDropDisplayIdx(displayIdx);
}, []);
const handleDrop = useCallback(() => {
if (dragId && dropDisplayIdx !== null) {
// Convert display index (reversed) → array index
// Display 0 = array last, Display N = array first
const totalLen = fields.length;
const targetArrayIdx = totalLen - 1 - dropDisplayIdx;
moveField(dragId, Math.max(0, Math.min(targetArrayIdx, totalLen - 1)));
}
setDragId(null);
setDropDisplayIdx(null);
}, [dragId, dropDisplayIdx, fields.length, moveField]);
const handleDragEnd = useCallback(() => {
setDragId(null);
setDropDisplayIdx(null);
}, []);
// ── Add handlers ──
const handleAddEditableSlot = useCallback((type: TemplateField['type'], label: string) => {
const newId = addField({
nature: 'editable-slot',
type,
label,
required: false,
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
const handleAddBrandVariable = useCallback((source: BrandSource, label: string, type: 'text' | 'image' | 'sticker') => {
const stickerDefaults: Partial<StickerConfig> | undefined = type === 'sticker'
? { ...DEFAULT_STICKER, showAtPrefix: source !== 'website' }
: undefined;
const newId = addField({
nature: 'brand-variable',
type,
label,
brandSource: source,
content: `{${source}}`,
...(stickerDefaults ? { style: { sticker: stickerDefaults as StickerConfig } } : {}),
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
const handleAddStatic = useCallback((type: TemplateField['type'], label: string) => {
const newId = addField({
nature: 'static',
type,
label,
content: label,
});
setSelectedFieldId(newId);
}, [addField, setSelectedFieldId]);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg">
{/* ── Header ── */}
<div className="p-3 border-b border-neutral-800 flex items-center justify-between shrink-0">
<div>
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Layers size={14} className="text-violet-400" />
Capas
</h3>
<p className="text-[9px] text-neutral-500 mt-0.5 font-mono">
{totalFieldCount} capa{totalFieldCount !== 1 ? 's' : ''} · {editableSlotCount} campo{editableSlotCount !== 1 ? 's' : ''}
</p>
</div>
{onClose && (
<button onClick={onClose} title="Cerrar panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors text-xs">
</button>
)}
</div>
{/* ── Layers list (full height, scrollable) ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
<div className="p-2 space-y-0">
{layersReversed.length === 0 ? (
<p className="text-[9px] text-neutral-600 text-center py-6 italic">
Sin capas. Agrega elementos abajo.
</p>
) : (
layersReversed.map((field, displayIdx) => (
<React.Fragment key={field.id}>
{/* Drop indicator line */}
{dropDisplayIdx === displayIdx && dragId !== field.id && (
<div className="h-0.5 bg-violet-500 rounded-full mx-1 my-0.5 shadow-sm shadow-violet-500/50" />
)}
<LayerRow
field={field}
isSelected={selectedFieldId === field.id}
isDragging={dragId === field.id}
onSelect={() => setSelectedFieldId(field.id)}
onRemove={() => removeField(field.id)}
onToggleVisible={() => updateField(field.id, { visible: field.visible === false ? true : false })}
onToggleLocked={() => updateField(field.id, { locked: !field.locked })}
onRename={(name) => updateField(field.id, { label: name })}
onDragStart={() => handleDragStart(field.id)}
onDragOver={() => handleDragOver(displayIdx)}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
</React.Fragment>
))
)}
{/* Drop indicator at very bottom */}
{dropDisplayIdx === layersReversed.length && (
<div className="h-0.5 bg-violet-500 rounded-full mx-1 my-0.5 shadow-sm shadow-violet-500/50" />
)}
</div>
<hr className="border-neutral-800/50 mx-3" />
{/* ═══ Add Fields ═══ */}
<div className="p-3 space-y-3">
{/* Editable slots */}
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">
Agregar campo editable
</span>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => handleAddEditableSlot('text', 'Texto')}
title="Agregar campo de texto editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => handleAddEditableSlot('image', 'Imagen')}
title="Agregar campo de imagen editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Imagen
</button>
<button
onClick={() => handleAddEditableSlot('video', 'Video')}
title="Agregar campo de video editable"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-sky-500/30 text-[9px] text-sky-400/70 hover:border-sky-500/60 hover:text-sky-300 hover:bg-sky-500/5 transition-all"
>
<Plus size={8} /> Video
</button>
<button
onClick={() => handleAddStatic('shape', 'Forma')}
title="Agregar elemento estático (forma)"
className="flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-neutral-600 hover:text-neutral-400 hover:bg-neutral-800/50 transition-all"
>
<Plus size={8} /> Forma
</button>
</div>
</div>
{/* Brand variables */}
<div className="space-y-1.5">
<span className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de marca
</span>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => handleAddBrandVariable(v.source, v.label, v.fieldType)}
title={`Insertar variable {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
};
// ═══════════════════════════════════════════════════════════════
// LayerRow — Individual layer in Photoshop-style list with DnD
// ═══════════════════════════════════════════════════════════════
interface LayerRowProps {
field: TemplateField;
isSelected: boolean;
isDragging: boolean;
onSelect: () => void;
onRemove: () => void;
onToggleVisible: () => void;
onToggleLocked: () => void;
onRename: (name: string) => void;
onDragStart: () => void;
onDragOver: () => void;
onDrop: () => void;
onDragEnd: () => void;
}
const LayerRow: React.FC<LayerRowProps> = ({
field,
isSelected,
isDragging,
onSelect,
onRemove,
onToggleVisible,
onToggleLocked,
onRename,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(field.label);
const isVisible = field.visible !== false;
const isLocked = field.locked === true;
const isBg = field.isBackground === true;
const canRename = !isBg && field.nature !== 'brand-variable';
const canDrag = !isBg;
const badge = NATURE_BADGE[field.nature];
const handleDoubleClick = useCallback(() => {
if (!canRename) return;
setRenameValue(field.label);
setIsRenaming(true);
}, [field.label, canRename]);
const commitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== field.label) {
onRename(trimmed);
}
setIsRenaming(false);
}, [renameValue, field.label, onRename]);
const handleRenameKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') commitRename();
if (e.key === 'Escape') { setRenameValue(field.label); setIsRenaming(false); }
}, [commitRename, field.label]);
return (
<div
onClick={onSelect}
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
onDrop={(e) => { e.preventDefault(); onDrop(); }}
className={`flex items-center gap-1 rounded-md px-1.5 py-1 cursor-pointer transition-all group ${
isSelected
? 'bg-violet-500/10 border border-violet-500/40 ring-1 ring-violet-500/20'
: 'bg-transparent border border-transparent hover:bg-neutral-800/60 hover:border-neutral-700/50'
} ${!isVisible ? 'opacity-40' : ''} ${isDragging ? 'opacity-30' : ''}`}
>
{/* Drag handle */}
{canDrag ? (
<div
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', field.id);
onDragStart();
}}
onDragEnd={onDragEnd}
className="cursor-grab active:cursor-grabbing text-neutral-600 hover:text-neutral-400 shrink-0 p-0.5"
title="Arrastrar para reordenar"
>
<GripVertical size={10} />
</div>
) : (
<div className="text-neutral-800 shrink-0 p-0.5" title="Capa de fondo (fija)">
<GripVertical size={10} />
</div>
)}
{/* Visibility toggle */}
<button
onClick={(e) => { e.stopPropagation(); onToggleVisible(); }}
title={isVisible ? 'Ocultar capa' : 'Mostrar capa'}
className="text-neutral-500 hover:text-white transition-colors p-0.5 shrink-0"
>
{isVisible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
{/* Lock toggle */}
<button
onClick={(e) => { e.stopPropagation(); onToggleLocked(); }}
title={isLocked ? 'Desbloquear capa' : 'Bloquear capa'}
className={`transition-colors p-0.5 shrink-0 ${
isLocked ? 'text-amber-400 hover:text-amber-300' : 'text-neutral-600 hover:text-neutral-400'
}`}
>
{isLocked ? <Lock size={10} /> : <Unlock size={10} />}
</button>
{/* Type icon */}
<span style={{ color: badge.color }} className="shrink-0">
{getTypeIcon(field.type)}
</span>
{/* Name (inline editable on double click) */}
{isRenaming && canRename ? (
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={handleRenameKeyDown}
autoFocus
className="flex-1 bg-neutral-800 border border-violet-500/50 rounded px-1 py-0.5 text-[10px] text-white focus:outline-none focus:ring-1 focus:ring-violet-500/40 min-w-0"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 text-[10px] text-neutral-300 truncate select-none"
onDoubleClick={canRename ? (e) => { e.stopPropagation(); handleDoubleClick(); } : undefined}
title={canRename ? 'Doble clic para renombrar' : 'Nombre heredado de la marca'}
>
{field.label}
</span>
)}
{/* Badge: FONDO for background, or nature badge */}
{isBg ? (
<span
className="text-[7px] px-1 py-0.5 rounded shrink-0 font-bold uppercase tracking-wider"
style={{ color: '#fbbf24', backgroundColor: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.30)' }}
>
fondo
</span>
) : (
<>
<span
className="text-[7px] px-1 py-0.5 rounded shrink-0 font-bold uppercase tracking-wider"
style={{ color: badge.color, backgroundColor: badge.bg, border: `1px solid ${badge.border}` }}
>
{badge.label}
</span>
{field.nature === 'editable-slot' && field.required && (
<span className="text-[7px] text-red-400 bg-red-500/10 px-1 py-0.5 rounded shrink-0 font-bold border border-red-500/20">
req
</span>
)}
</>
)}
{/* Delete (hover only, not for background) */}
{!isBg && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
title="Eliminar capa"
className="text-neutral-600 hover:text-red-400 transition-colors p-px opacity-0 group-hover:opacity-100 shrink-0"
>
<Trash2 size={9} />
</button>
)}
</div>
);
};
@@ -0,0 +1,196 @@
import React from 'react';
import {
Zap, FileText, Hash, Film,
} from 'lucide-react';
import { TemplateField, DesignMD, CompanyProfile, ExpressScene } from '../../../types';
import { useTemplateBuilder } from '../../../context/TemplateBuilderContext';
import { TemplateFieldInput } from '../../shared/TemplateFieldInput';
/** Resolve brand variable preview text */
function resolveBrandPreview(field: TemplateField, designMD: DesignMD, company: CompanyProfile): string {
if (!field.brandSource) return '';
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || 'Tu Marca';
case 'tagline': return company.tagline || '';
case 'logo': return '(Logo de marca)';
case 'instagram': return company.socialLinks?.instagram || '';
case 'tiktok': return company.socialLinks?.tiktok || '';
case 'twitter': return company.socialLinks?.x || '';
case 'youtube': return company.socialLinks?.youtube || '';
case 'website': return company.socialLinks?.website || '';
default: return '';
}
}
/**
* FormPreviewPanel Preview of the auto-generated form that the end-user will see in Express.
*
* Shows only editable-slot fields in their formOrder, rendered as the appropriate input type.
* Brand variables appear as read-only info rows (not editable).
* This is the "Vista de formulario" toggle in the builder.
*
* Uses the shared TemplateFieldInput component in disabled mode.
*/
export const FormPreviewPanel: React.FC = () => {
const {
fields,
designMD,
company,
templateMeta,
editableSlotCount,
scenes,
} = useTemplateBuilder();
// Detect segments
const formSegments = scenes.filter(
(s: ExpressScene) => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'form'
);
const brandSegments = scenes.filter(
(s: ExpressScene) => (s.type === 'intro' || s.type === 'outro') && s.segmentSource === 'brand'
);
const editableSlots = fields
.filter(f => f.nature === 'editable-slot')
.sort((a, b) => a.formOrder - b.formOrder);
const brandVars = fields.filter(f => f.nature === 'brand-variable');
return (
<div className="flex-1 flex items-start justify-center bg-neutral-950 p-6 overflow-auto min-h-0">
{/* Form card */}
<div className="w-full max-w-md bg-neutral-900 border border-neutral-800 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden">
{/* Header */}
<div className="px-6 py-5 border-b border-neutral-800 bg-gradient-to-r from-sky-500/5 to-violet-500/5">
<div className="flex items-center gap-2 mb-2">
<FileText size={16} className="text-sky-400" />
<h2 className="text-sm font-bold text-white">Vista previa del formulario</h2>
</div>
<p className="text-[10px] text-neutral-500">
Este es el formulario que verá quien produzca contenido con esta plantilla.
</p>
<div className="mt-2 flex items-center gap-2">
<span className="text-[9px] text-sky-400 bg-sky-500/10 px-2 py-0.5 rounded-full font-medium">
<Hash size={8} className="inline mr-0.5" />
{editableSlotCount} campo{editableSlotCount !== 1 ? 's' : ''}
</span>
<span className="text-[9px] text-neutral-500">{templateMeta.name || 'Plantilla'}</span>
</div>
</div>
{/* Form fields */}
<div className="p-6 space-y-5">
{/* ── Form-sourced segment fields (video upload previews) ── */}
{formSegments.length > 0 && (
<div className="space-y-3 pb-4 border-b border-neutral-800/50 mb-4">
<div className="flex items-center gap-2">
<Film size={12} className="text-emerald-400" />
<span className="text-[10px] font-bold text-white uppercase tracking-wider">Segmentos de video</span>
</div>
{formSegments.map((scene: ExpressScene) => {
const isIntro = scene.type === 'intro';
const syntheticField: TemplateField = {
id: `segment-${scene.id}`,
nature: 'editable-slot',
type: 'video',
label: scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre'),
required: scene.segmentFieldRequired ?? true,
content: isIntro ? 'Video de intro' : 'Video de cierre',
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: isIntro ? -2 : 999,
};
return (
<TemplateFieldInput
key={syntheticField.id}
field={syntheticField}
value=""
onChange={() => {}}
designMD={designMD}
disabled
/>
);
})}
</div>
)}
{editableSlots.length === 0 && formSegments.length === 0 ? (
<div className="text-center py-8">
<Hash size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">No hay campos editables.</p>
<p className="text-[10px] text-neutral-600 mt-1">
Agrega campos desde el panel "Campos" para que aparezcan aquí.
</p>
</div>
) : editableSlots.length === 0 ? null : (
editableSlots.map((field) => (
<TemplateFieldInput
key={field.id}
field={field}
value=""
onChange={() => {}}
designMD={designMD}
disabled
/>
))
)}
{/* Brand-sourced segments (read-only info) */}
{brandSegments.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-emerald-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Segmentos automáticos
</p>
<div className="space-y-2">
{brandSegments.map((scene: ExpressScene) => (
<div
key={scene.id}
className="flex items-center gap-3 px-3 py-2.5 bg-emerald-500/5 border border-emerald-500/15 rounded-lg"
>
<Film size={10} className="text-emerald-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-emerald-300 font-medium">{scene.name}</span>
<span className="text-[9px] text-emerald-400/50 block">
{scene.durationSeconds}s desde la marca
</span>
</div>
<span className="text-[7px] text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
{/* Brand variables (read-only info) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde la marca
</p>
<div className="space-y-2">
{brandVars.map(field => (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{resolveBrandPreview(field, designMD, company)}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,68 @@
import React from 'react';
import { Globe } from 'lucide-react';
import { BrandSource } from '../../../types';
/**
* PlatformIcons Inline SVG icons for social platforms.
* Used by BrandStickerContent to render the icon portion of a sticker.
*/
/** Instagram icon */
const InstagramIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
/** TikTok icon */
const TikTokIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1V9.01a6.27 6.27 0 00-.79-.05 6.34 6.34 0 00-6.34 6.34 6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.33-6.34V9.19a8.16 8.16 0 004.77 1.53V7.27a4.84 4.84 0 01-1-.58z" />
</svg>
);
/** X (Twitter) icon */
const XIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
/** YouTube icon */
const YouTubeIcon: React.FC<{ size: number }> = ({ size }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
/** Website / Globe icon — reuses Lucide Globe */
const WebsiteIcon: React.FC<{ size: number }> = ({ size }) => (
<Globe size={size} />
);
/** Get the platform icon component for a given brand source */
export function getPlatformIcon(source: BrandSource | string | undefined, size: number): React.ReactNode {
switch (source) {
case 'instagram': return <InstagramIcon size={size} />;
case 'tiktok': return <TikTokIcon size={size} />;
case 'twitter': return <XIcon size={size} />;
case 'youtube': return <YouTubeIcon size={size} />;
case 'website': return <WebsiteIcon size={size} />;
default: return null;
}
}
/** Brand sources that produce stickers (icon + text) */
export const SOCIAL_SOURCES: string[] = ['instagram', 'tiktok', 'twitter', 'youtube', 'website'];
/** Check if a brand source should produce a sticker */
export const isSocialSource = (source?: string): boolean => SOCIAL_SOURCES.includes(source || '');
/** Default sticker configuration */
export const DEFAULT_STICKER = {
showIcon: true,
iconPosition: 'left' as const,
showAtPrefix: true,
stickerStyle: 'plain' as const,
gap: 6,
};
@@ -0,0 +1,306 @@
import React from 'react';
import { Film, Plus, X, ArrowRight, Volume2, Music, Camera } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
import { SegmentCard } from './SegmentCard';
interface SceneComposerProps {
scenes: ExpressScene[];
activeSceneId: string | null;
onSelectScene: (sceneId: string) => void;
onAddScene: () => void;
onRemoveScene: (sceneId: string) => void;
designMD: DesignMD;
usesBrandAudio: boolean;
format: 'video' | 'image';
// Segment management
onAddSegment: (position: 'before' | 'after', source: 'brand' | 'form') => void;
onRemoveSegment: (position: 'before' | 'after') => void;
onUpdateSegment: (sceneId: string, updates: Partial<ExpressScene>) => void;
previewBrand: CompanyProfile | null;
}
/** Color mapping for scene types */
const TYPE_COLORS: Record<string, string> = {
intro: '#10b981',
content: '#8b5cf6',
outro: '#f43f5e',
transition: '#3b82f6',
};
const TYPE_ICONS: Record<string, React.ReactNode> = {
intro: <Film size={12} />,
content: <Camera size={12} />,
outro: <Film size={12} />,
transition: <ArrowRight size={12} />,
};
/**
* SceneComposer Visual block composition for video templates.
*
* Layout: [Intro segment?] [Content scene blocks + Add] [Outro segment?]
* Below track: [+ Antes] button (if no intro) | [+ Después] button (if no outro)
*/
export const SceneComposer: React.FC<SceneComposerProps> = ({
scenes,
activeSceneId,
onSelectScene,
onAddScene,
onRemoveScene,
designMD,
usesBrandAudio,
format,
onAddSegment,
onRemoveSegment,
onUpdateSegment,
previewBrand,
}) => {
// Separate segments from content scenes
const introScene = scenes.find(s => s.type === 'intro') || null;
const outroScene = scenes.find(s => s.type === 'outro') || null;
const contentScenes = scenes.filter(s => s.type === 'content' || s.type === 'transition');
const totalDur = scenes.reduce((sum, s) => sum + s.durationSeconds, 0);
const contentDur = contentScenes.reduce((sum, s) => sum + s.durationSeconds, 0);
const hasAudio = usesBrandAudio && !!designMD.brandAudioUrl;
return (
<div className="bg-neutral-900/80 border border-neutral-800 rounded-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Film size={14} className="text-neutral-500" />
<h4 className="text-[10px] font-mono uppercase tracking-widest text-neutral-500">
Composición de Escenas
</h4>
</div>
<span className="text-[10px] font-mono text-neutral-600">
{totalDur.toFixed(1)}s · {scenes.length} escena{scenes.length !== 1 ? 's' : ''}
</span>
</div>
{/* Video Track */}
<div className="px-4 pt-3 pb-1">
<div className="flex items-center gap-0.5 text-[8px] text-neutral-500 mb-1.5 font-mono uppercase tracking-widest">
<Film size={9} /> {format === 'video' ? 'Video' : 'Imagen'}
</div>
<div className="flex items-stretch gap-2 min-h-[48px]">
{/* ── Intro Segment ── */}
{introScene && (
<>
<div
onClick={() => onSelectScene(introScene.id)}
className={`cursor-pointer rounded-xl transition-all ${activeSceneId === introScene.id ? 'ring-2 ring-emerald-500/60 ring-offset-1 ring-offset-neutral-950' : 'hover:ring-1 hover:ring-neutral-600/50'}`}
>
<SegmentCard
scene={introScene}
position="before"
designMD={designMD}
previewBrand={previewBrand}
onSourceChange={(source) => onUpdateSegment(introScene.id, {
segmentSource: source,
name: source === 'brand' ? 'Intro de marca' : 'Video de intro',
segmentFieldLabel: source === 'form' ? 'Video de intro' : undefined,
segmentFieldRequired: source === 'form' ? true : undefined,
})}
onDurationChange={(seconds) => onUpdateSegment(introScene.id, { durationSeconds: seconds })}
onLabelChange={(label) => onUpdateSegment(introScene.id, { segmentFieldLabel: label })}
onRequiredChange={(required) => onUpdateSegment(introScene.id, { segmentFieldRequired: required })}
onTransitionChange={(type) => onUpdateSegment(introScene.id, {
segmentTransition: { type, duration: introScene.segmentTransition?.duration || 10 },
})}
onRemove={() => onRemoveSegment('before')}
/>
</div>
{/* Arrow between intro and content */}
<div className="flex flex-col items-center justify-center shrink-0 px-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
</>
)}
{/* ── Content block: fixed "Contenido" badge ── */}
<div className="flex items-center gap-1 flex-1 min-w-0">
{contentScenes.map((scene, i) => {
const color = TYPE_COLORS[scene.type] || TYPE_COLORS.content;
const isActive = activeSceneId === scene.id;
const widthPct = contentDur > 0 ? (scene.durationSeconds / contentDur) * 100 : 25;
const canRemove = contentScenes.length > 1;
return (
<React.Fragment key={scene.id}>
<button
onClick={() => onSelectScene(scene.id)}
title={`${scene.name}${scene.durationSeconds}s · Click para editar`}
className={`h-12 rounded-lg flex flex-col items-center justify-center text-center transition-all relative overflow-hidden group cursor-pointer ${
isActive
? 'ring-2 ring-offset-1 ring-offset-neutral-900 scale-[1.02] z-10'
: 'hover:scale-[1.01]'
}`}
style={{
flex: `${Math.max(widthPct, 12)} 0 0`,
minWidth: '80px',
backgroundColor: isActive ? `${color}30` : `${color}15`,
border: `1px solid ${isActive ? color : `${color}40`}`,
['--tw-ring-color' as string]: color,
}}
>
{/* Shimmer on active */}
{isActive && (
<div
className="absolute inset-0 opacity-20"
style={{
background: `linear-gradient(90deg, transparent, ${color}40, transparent)`,
animation: 'shimmer 2s infinite',
}}
/>
)}
<span style={{ color }} className="mb-0.5 opacity-80 relative z-10">
{TYPE_ICONS[scene.type]}
</span>
<span className="text-[8px] font-bold tracking-wider text-white/80 relative z-10">
{scene.name.toUpperCase()}
</span>
{format === 'video' && (
<span className="text-[8px] font-mono text-neutral-400 relative z-10">
{scene.durationSeconds}s
</span>
)}
{/* Remove button */}
{canRemove && isActive && (
<button
onClick={(e) => { e.stopPropagation(); onRemoveScene(scene.id); }}
title="Eliminar escena"
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full flex items-center justify-center text-[8px] hover:bg-red-400 transition-colors z-20"
>
<X size={8} />
</button>
)}
</button>
{/* Transition dot between content scenes */}
{i < contentScenes.length - 1 && (
<div className="flex flex-col items-center shrink-0 px-0.5 gap-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
)}
</React.Fragment>
);
})}
{/* Add content scene button */}
<button
onClick={onAddScene}
title="Agregar escena de contenido"
className="h-12 min-w-[40px] rounded-lg border-2 border-dashed border-neutral-700 flex items-center justify-center text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 hover:bg-violet-500/5 transition-all cursor-pointer shrink-0"
>
<Plus size={14} />
</button>
</div>
{/* ── Outro Segment ── */}
{outroScene && (
<>
{/* Arrow between content and outro */}
<div className="flex flex-col items-center justify-center shrink-0 px-0.5">
<div className="w-5 h-5 rounded-full bg-neutral-800/80 border border-neutral-700 flex items-center justify-center">
<ArrowRight size={8} className="text-neutral-400" />
</div>
</div>
<div
onClick={() => onSelectScene(outroScene.id)}
className={`cursor-pointer rounded-xl transition-all ${activeSceneId === outroScene.id ? 'ring-2 ring-emerald-500/60 ring-offset-1 ring-offset-neutral-950' : 'hover:ring-1 hover:ring-neutral-600/50'}`}
>
<SegmentCard
scene={outroScene}
position="after"
designMD={designMD}
previewBrand={previewBrand}
onSourceChange={(source) => onUpdateSegment(outroScene.id, {
segmentSource: source,
name: source === 'brand' ? 'Outro de marca' : 'Video de cierre',
segmentFieldLabel: source === 'form' ? 'Video de cierre' : undefined,
segmentFieldRequired: source === 'form' ? true : undefined,
})}
onDurationChange={(seconds) => onUpdateSegment(outroScene.id, { durationSeconds: seconds })}
onLabelChange={(label) => onUpdateSegment(outroScene.id, { segmentFieldLabel: label })}
onRequiredChange={(required) => onUpdateSegment(outroScene.id, { segmentFieldRequired: required })}
onTransitionChange={(type) => onUpdateSegment(outroScene.id, {
segmentTransition: { type, duration: outroScene.segmentTransition?.duration || 10 },
})}
onRemove={() => onRemoveSegment('after')}
/>
</div>
</>
)}
</div>
{/* ── Add segment buttons (below track) ── */}
{format === 'video' && (!introScene || !outroScene) && (
<div className="flex items-center gap-2 mt-2">
{!introScene && (
<button
onClick={() => onAddSegment('before', 'brand')}
title="Agregar contenido antes (intro)"
className="flex-1 h-8 rounded-lg border border-dashed border-neutral-700 flex items-center justify-center gap-1.5 text-neutral-500 hover:border-emerald-500/50 hover:text-emerald-400 hover:bg-emerald-500/5 transition-all cursor-pointer text-[9px] font-medium"
>
<Plus size={10} /> Antes
</button>
)}
{!outroScene && (
<button
onClick={() => onAddSegment('after', 'brand')}
title="Agregar contenido después (outro)"
className="flex-1 h-8 rounded-lg border border-dashed border-neutral-700 flex items-center justify-center gap-1.5 text-neutral-500 hover:border-rose-500/50 hover:text-rose-400 hover:bg-rose-500/5 transition-all cursor-pointer text-[9px] font-medium"
>
<Plus size={10} /> Después
</button>
)}
</div>
)}
</div>
{/* Audio Track (only for video) */}
{format === 'video' && (
<div className="px-4 pb-3 pt-1">
<div className="flex items-center gap-0.5 text-[8px] text-neutral-500 mb-1.5 font-mono uppercase tracking-widest">
<Volume2 size={9} /> Audio
</div>
<div
className={`w-full h-7 rounded-lg border flex items-center gap-2 px-3 ${
hasAudio
? 'border-neutral-800 bg-neutral-950'
: 'border-neutral-800 bg-transparent'
}`}
>
{hasAudio ? (
<>
<div className="flex items-end gap-[1px] h-4 flex-1">
{Array.from({ length: 48 }).map((_, i) => (
<div
key={i}
className="flex-1 rounded-full bg-neutral-600"
style={{
height: `${Math.max(2, Math.sin(i * 0.4) * 10 + Math.random() * 5 + 3)}px`,
}}
/>
))}
</div>
<span className="text-[9px] font-mono text-neutral-500 shrink-0">🔊 Auto</span>
</>
) : (
<span className="text-[9px] text-neutral-600 font-medium flex items-center gap-1.5 mx-auto">
<Music size={10} /> Sin audio de marca
</span>
)}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,357 @@
import React from 'react';
import { Type, Image as ImageIcon, Plus, Trash2, Zap, Clock, Layers, Sparkles, Globe, Instagram, AtSign } from 'lucide-react';
import { ExpressScene, ExpressField, SceneLayout, BrandContentPiece, DesignMD } from '../../../types';
import { FieldInspector } from '../../ui/FieldInspector';
interface SceneConfiguratorProps {
scene: ExpressScene;
onUpdateScene: (updated: ExpressScene) => void;
brandContent: BrandContentPiece[];
designMD: DesignMD;
isVideo: boolean;
selectedFieldId?: string | null;
onSelectField?: (fieldId: string | null) => void;
}
/** Layout options with visual icons */
const LAYOUTS: { value: SceneLayout; label: string; icon: string }[] = [
{ value: 'fullscreen-media', label: 'Pantalla completa', icon: '📸' },
{ value: 'overlay', label: 'Overlay', icon: '🔲' },
{ value: 'split', label: 'Dividido', icon: '◫' },
{ value: 'media-left', label: 'Media izq.', icon: '◧' },
{ value: 'media-right', label: 'Media der.', icon: '◨' },
{ value: 'text-only', label: 'Solo texto', icon: '📝' },
];
/** Brand variables available for insertion */
const BRAND_VARIABLES: { source: ExpressField['brandSource']; label: string; icon: React.ReactNode; type: 'text' | 'media' | 'logo' }[] = [
{ source: 'brand-name', label: 'Nombre de Marca', icon: <Type size={10} />, type: 'text' },
{ source: 'tagline', label: 'Tagline / Eslogan', icon: <Sparkles size={10} />, type: 'text' },
{ source: 'logo', label: 'Logo', icon: <Zap size={10} />, type: 'logo' },
{ source: 'instagram', label: 'Instagram', icon: <Instagram size={10} />, type: 'text' },
{ source: 'tiktok', label: 'TikTok', icon: <AtSign size={10} />, type: 'text' },
{ source: 'twitter', label: 'X / Twitter', icon: <AtSign size={10} />, type: 'text' },
{ source: 'youtube', label: 'YouTube', icon: <AtSign size={10} />, type: 'text' },
{ source: 'website', label: 'Website', icon: <Globe size={10} />, type: 'text' },
];
/**
* SceneConfigurator Config panel for the active scene in the Template Builder.
* Name, type, duration, layout, editable fields, brand assets, transition, background.
*/
export const SceneConfigurator: React.FC<SceneConfiguratorProps> = ({
scene,
onUpdateScene,
brandContent,
designMD,
isVideo,
selectedFieldId,
onSelectField,
}) => {
const updateField = (fieldId: string, updates: Partial<ExpressField>) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.map(f =>
f.id === fieldId ? { ...f, ...updates } : f
),
});
};
const addField = (type: ExpressField['type'], label: string, brandSource?: ExpressField['brandSource']) => {
const newField: ExpressField = {
id: `field-${Date.now()}`,
type,
label,
placeholder: label,
required: false,
brandSource,
position: { x: 50, y: 50, w: type === 'text' ? 80 : 60, h: type === 'text' ? 10 : 30 },
style: {
fontSize: type === 'text' ? 24 : undefined,
fontWeight: type === 'text' ? 400 : undefined,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const addBrandAsset = (asset: BrandContentPiece) => {
const newField: ExpressField = {
id: `field-asset-${asset.id}-${Date.now()}`,
type: asset.type === 'custom-image' ? 'media' : 'text',
label: asset.name,
placeholder: asset.content.text || asset.name,
required: false,
brandAssetId: asset.id,
position: { x: 50, y: 50, w: 40, h: 20 },
style: {
fontSize: asset.style.fontSize || 20,
fontWeight: 600,
textAlign: 'center',
opacity: 100,
},
};
onUpdateScene({ ...scene, editableFields: [...scene.editableFields, newField] });
};
const removeField = (fieldId: string) => {
onUpdateScene({
...scene,
editableFields: scene.editableFields.filter(f => f.id !== fieldId),
});
};
return (
<div className="space-y-4">
{/* Scene name + type */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Nombre de la escena</label>
<input
type="text"
value={scene.name}
onChange={(e) => onUpdateScene({ ...scene, name: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-sm text-white focus:border-violet-500/50 focus:outline-none"
/>
</div>
{/* Type + Duration (video only) */}
{isVideo && (
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Tipo</label>
<select
value={scene.type}
onChange={(e) => onUpdateScene({ ...scene, type: e.target.value as ExpressScene['type'] })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none"
>
<option value="intro">Intro</option>
<option value="content">Contenido</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="w-20 space-y-1">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Clock size={8} /> Duración
</label>
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={30}
value={scene.durationSeconds}
onChange={(e) => onUpdateScene({ ...scene, durationSeconds: Math.max(1, parseInt(e.target.value) || 1) })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-xs text-white text-center focus:border-violet-500/50 focus:outline-none"
/>
<span className="text-[9px] text-neutral-500">s</span>
</div>
</div>
</div>
)}
{/* Layout */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Layers size={8} /> Layout
</label>
<div className="grid grid-cols-3 gap-1">
{LAYOUTS.map(l => (
<button
key={l.value}
onClick={() => onUpdateScene({ ...scene, layout: l.value })}
title={l.label}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.layout === l.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 hover:border-neutral-600'
}`}
>
<span>{l.icon}</span> {l.label}
</button>
))}
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Editable Fields */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Campos editables</label>
<span className="text-[8px] text-neutral-600">{scene.editableFields.length} campos</span>
</div>
{scene.editableFields.map(field => {
const isSelected = selectedFieldId === field.id;
return (
<div
key={field.id}
onClick={() => onSelectField?.(field.id)}
className={`flex items-center gap-2 rounded-lg px-2.5 py-1.5 cursor-pointer transition-all ${
isSelected
? 'bg-violet-500/10 border border-violet-500/40 ring-1 ring-violet-500/20'
: 'bg-neutral-800/50 border border-neutral-700/50 hover:border-neutral-600'
}`}
>
<span className="text-[10px]">
{field.type === 'text' ? '📝' : field.type === 'media' ? '📷' : '⚡'}
</span>
<input
type="text"
value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value, placeholder: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-[10px] text-neutral-300 focus:outline-none"
/>
{field.brandSource && (
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1 py-0.5 rounded shrink-0">
{`{${field.brandSource}}`}
</span>
)}
{field.brandAssetId && (
<span className="text-[7px] text-amber-400 bg-amber-500/10 px-1 py-0.5 rounded shrink-0">
Asset
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); removeField(field.id); }}
title="Quitar campo"
className="text-neutral-600 hover:text-red-400 transition-colors shrink-0"
>
<Trash2 size={10} />
</button>
</div>
);
})}
{/* Field Inspector (when a field is selected) */}
{selectedFieldId && (() => {
const field = scene.editableFields.find(f => f.id === selectedFieldId);
if (!field) return null;
const brandColors = [designMD.primaryColor, designMD.secondaryColor, designMD.textColor].filter(Boolean);
return (
<FieldInspector
position={field.position}
onPositionChange={(pos) => {
updateField(field.id, {
position: { ...field.position, ...pos },
});
}}
textStyle={field.type === 'text' ? {
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
fontFamily: field.style.fontFamily,
color: field.style.color,
textAlign: field.style.textAlign as 'left' | 'center' | 'right' | undefined,
opacity: field.style.opacity,
} : undefined}
onTextStyleChange={field.type === 'text' ? (style) => {
updateField(field.id, {
style: { ...field.style, ...style },
});
} : undefined}
fieldType={field.type as 'text' | 'media' | 'logo'}
fieldLabel={field.label}
brandFont={designMD.baseFont?.split(',')[0]?.replace(/"/g, '')}
brandColors={brandColors}
/>
);
})()}
{/* Add field buttons */}
<div className="flex gap-1">
<button
onClick={() => addField('text', 'Texto')}
title="Agregar campo de texto"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-violet-500/50 hover:text-violet-400 transition-all"
>
<Plus size={8} /> Texto
</button>
<button
onClick={() => addField('media', 'Media')}
title="Agregar campo de media"
className="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg border border-dashed border-neutral-700 text-[9px] text-neutral-500 hover:border-sky-500/50 hover:text-sky-400 transition-all"
>
<Plus size={8} /> Media
</button>
</div>
</div>
<hr className="border-neutral-800/50" />
{/* Brand Variables (social handles, name, etc.) */}
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<Zap size={8} className="text-violet-400" /> Variables de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{BRAND_VARIABLES.map(v => (
<button
key={v.source}
onClick={() => addField(v.type, v.label, v.source)}
title={`Insertar {${v.source}}`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-violet-500/5 border border-violet-500/15 text-[9px] text-violet-300 hover:bg-violet-500/10 hover:border-violet-500/30 transition-all"
>
{v.icon} {v.label}
</button>
))}
</div>
</div>
{/* Brand Content Assets */}
{brandContent.length > 0 && (
<>
<hr className="border-neutral-800/50" />
<div className="space-y-2">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold flex items-center gap-1">
<ImageIcon size={8} className="text-amber-400" /> Assets de Marca
</label>
<div className="grid grid-cols-2 gap-1">
{brandContent.map(asset => (
<button
key={asset.id}
onClick={() => addBrandAsset(asset)}
title={`Insertar asset: ${asset.name} (ID: ${asset.id})`}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg bg-amber-500/5 border border-amber-500/15 text-[9px] text-amber-300 hover:bg-amber-500/10 hover:border-amber-500/30 transition-all text-left truncate"
>
{asset.thumbnail ? (
<img src={asset.thumbnail} alt="" className="w-4 h-4 rounded object-cover shrink-0" />
) : (
<div className="w-4 h-4 rounded bg-amber-500/20 shrink-0" />
)}
<span className="truncate">{asset.name}</span>
</button>
))}
</div>
</div>
</>
)}
<hr className="border-neutral-800/50" />
{/* Background */}
<div className="space-y-1.5">
<label className="text-[9px] text-neutral-500 uppercase tracking-wider font-semibold">Fondo</label>
<div className="flex gap-1">
{(['brand', 'solid', 'gradient', 'media'] as const).map(bg => (
<button
key={bg}
onClick={() => onUpdateScene({ ...scene, background: { type: bg } })}
title={`Fondo: ${bg}`}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
scene.background?.type === bg
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-neutral-800 border-neutral-700 text-neutral-500 hover:text-neutral-300'
}`}
>
{bg === 'brand' ? '🎨 Marca' : bg === 'solid' ? '⬛ Sólido' : bg === 'gradient' ? '🌈 Grad' : '📷 Media'}
</button>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,236 @@
import React from 'react';
import { X, Zap, FileText, Clock, AlertTriangle, Film } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
interface SegmentCardProps {
scene: ExpressScene;
position: 'before' | 'after';
designMD: DesignMD;
previewBrand: CompanyProfile | null;
onSourceChange: (source: 'brand' | 'form') => void;
onDurationChange: (seconds: number) => void;
onLabelChange: (label: string) => void;
onRequiredChange: (required: boolean) => void;
onTransitionChange: (type: string) => void;
onRemove: () => void;
}
const TRANSITION_OPTIONS = [
{ value: 'none', label: 'Sin transición' },
{ value: 'fade', label: 'Fundido' },
{ value: 'slideUp', label: 'Deslizar ↑' },
{ value: 'slideDown', label: 'Deslizar ↓' },
{ value: 'slideLeft', label: 'Deslizar ←' },
{ value: 'slideRight', label: 'Deslizar →' },
{ value: 'scale', label: 'Escala' },
];
/**
* SegmentCard Visual card for an intro/outro segment in the SceneComposer.
*
* Shows a source toggle (Marca/Formulario), duration, badge, and description.
* Matches the boceto design with dashed borders and pill-style toggles.
*/
export const SegmentCard: React.FC<SegmentCardProps> = ({
scene,
position,
designMD,
previewBrand,
onSourceChange,
onDurationChange,
onLabelChange,
onRequiredChange,
onTransitionChange,
onRemove,
}) => {
const isIntro = position === 'before';
const isBrand = scene.segmentSource === 'brand';
// Check if brand has the required video
const brandVideoUrl = isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl;
const hasBrandVideo = !!brandVideoUrl;
const brandMissing = isBrand && !hasBrandVideo;
const borderColor = isBrand ? '#8b5cf6' : '#3b82f6';
const badgeBg = isBrand ? 'bg-violet-500/15' : 'bg-sky-500/15';
const badgeText = isBrand ? 'text-violet-300' : 'text-sky-300';
const badgeBorder = isBrand ? 'border-violet-500/30' : 'border-sky-500/30';
return (
<div
className="relative rounded-xl overflow-hidden transition-all group"
style={{
border: `1.5px ${isBrand ? 'solid' : 'dashed'} ${borderColor}40`,
backgroundColor: `${borderColor}08`,
minWidth: 160,
maxWidth: 200,
}}
>
{/* Header: title + duration + remove */}
<div className="flex items-center justify-between px-3 pt-2.5 pb-1">
<span className="text-[9px] font-bold text-white/80 tracking-wider uppercase">
{isIntro ? 'Antes' : 'Después'}
</span>
<div className="flex items-center gap-1.5">
{/* Duration */}
<div className="flex items-center gap-0.5" title="Duración del segmento">
<Clock size={8} className="text-neutral-500" />
<input
type="number"
value={scene.durationSeconds}
onChange={(e) => onDurationChange(Math.max(1, Number(e.target.value)))}
title="Duración en segundos"
className="w-8 bg-transparent text-[9px] font-mono text-neutral-400 text-right border-none outline-none"
step={0.5}
min={1}
max={30}
/>
<span className="text-[8px] text-neutral-600">s</span>
</div>
{/* Remove */}
<button
onClick={onRemove}
title={`Eliminar ${isIntro ? 'intro' : 'outro'}`}
className="w-4 h-4 rounded-full bg-red-500/20 text-red-400 hover:bg-red-500/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={8} />
</button>
</div>
</div>
{/* Source toggle */}
<div className="px-3 pb-2">
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/40 p-0.5">
<button
onClick={() => onSourceChange('brand')}
title="Usar video de intro/outro de la marca (automático)"
className={`flex-1 px-2 py-1 rounded-md text-[8px] font-semibold transition-all ${
isBrand
? 'bg-violet-600/30 text-violet-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
Marca
</button>
<button
onClick={() => onSourceChange('form')}
title="Pedir al productor que suba el video en el formulario"
className={`flex-1 px-2 py-1 rounded-md text-[8px] font-semibold transition-all ${
!isBrand
? 'bg-sky-600/30 text-sky-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
Formulario
</button>
</div>
</div>
{/* Badge */}
<div className="px-3 pb-1.5 flex items-center justify-center">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[8px] font-bold border ${badgeBg} ${badgeText} ${badgeBorder}`}>
{isBrand ? (
<><Zap size={7} /> Auto</>
) : (
<><FileText size={7} /> Campo</>
)}
</span>
</div>
{/* Description */}
<div className="px-3 pb-3 text-center">
{isBrand ? (
<>
<p className="text-[9px] text-white/70 font-medium">
{isIntro ? 'Intro de la marca' : 'Outro de la marca'}
</p>
<p className="text-[8px] text-neutral-500 mt-0.5">
{previewBrand
? (hasBrandVideo ? 'desde el Design MD' : '')
: 'desde el Design MD'}
</p>
{brandMissing && previewBrand && (
<div className="flex items-center gap-1 justify-center mt-1.5 text-amber-400">
<AlertTriangle size={8} />
<span className="text-[7px] font-medium">
{previewBrand.name} no tiene {isIntro ? 'intro' : 'outro'}
</span>
</div>
)}
{hasBrandVideo && previewBrand && (
<div className="mt-1.5 rounded-md overflow-hidden border border-neutral-700/30" style={{ height: 36 }}>
<video
src={brandVideoUrl}
muted
className="w-full h-full object-cover"
style={{ pointerEvents: 'none' }}
/>
</div>
)}
</>
) : (
<>
<p className="text-[9px] text-white/70 font-medium">
{scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre')}
</p>
<p className="text-[8px] text-neutral-500 mt-0.5">
el productor lo sube
</p>
{/* Label config */}
<input
type="text"
value={scene.segmentFieldLabel || ''}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="Etiqueta del campo"
title="Nombre del campo en el formulario"
className="mt-1.5 w-full bg-neutral-800/50 border border-neutral-700/40 rounded-md px-2 py-1 text-[8px] text-white placeholder-neutral-600 outline-none focus:border-sky-500/40"
/>
<label className="flex items-center gap-1 justify-center mt-1 cursor-pointer" title="¿Es obligatorio subir este video?">
<input
type="checkbox"
checked={scene.segmentFieldRequired ?? true}
onChange={(e) => onRequiredChange(e.target.checked)}
className="w-3 h-3 rounded bg-neutral-800 border-neutral-700 accent-sky-500"
/>
<span className="text-[7px] text-neutral-500">Obligatorio</span>
</label>
</>
)}
</div>
{/* Transition selector */}
<div className="px-3 pb-2.5 border-t border-neutral-800/40 pt-2">
<div className="flex items-center gap-1">
<Film size={7} className="text-neutral-500 shrink-0" />
<select
value={scene.segmentTransition?.type || 'fade'}
onChange={(e) => onTransitionChange(e.target.value)}
title="Transición del segmento"
className="flex-1 bg-transparent text-[8px] text-neutral-400 border-none outline-none cursor-pointer"
>
{TRANSITION_OPTIONS.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{/* Position summary */}
{(scene.segmentVideoX != null || scene.segmentVideoW != null) && (
<div className="px-3 pb-2 flex items-center justify-center gap-1 text-[7px] text-emerald-400/60 font-mono">
<span>📐</span>
<span>
{(scene.segmentVideoX ?? 50).toFixed(0)},{(scene.segmentVideoY ?? 50).toFixed(0)}
</span>
<span></span>
<span>
{(scene.segmentVideoW ?? 100).toFixed(0)}×{(scene.segmentVideoH ?? 100).toFixed(0)}
</span>
{scene.segmentVideoFit && scene.segmentVideoFit !== 'cover' && (
<span className="text-emerald-400/40">({scene.segmentVideoFit})</span>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,275 @@
import React, { useRef, useCallback, useMemo } from 'react';
import { Move, Maximize2, Film, AlertTriangle, FileText } from 'lucide-react';
import { ExpressScene, DesignMD, CompanyProfile } from '../../../types';
import { getAspectDimensions } from '../../../utils/expressCompiler';
import { useDragResize } from '../../../hooks/useDragResize';
const SEGMENT_VIDEO_ID = 'segment-video-frame';
interface SegmentVideoFrameProps {
scene: ExpressScene;
designMD: DesignMD;
previewBrand: CompanyProfile | null;
aspectRatio: string;
onPositionChange: (updates: Partial<ExpressScene>) => void;
}
/**
* SegmentVideoFrame Draggable/resizable video element for intro/outro segments.
*
* Rendered on the BuilderCanvas when the active scene is a segment (intro/outro).
* Uses the shared `useDragResize` hook per AGENTS.md rules.
* Shows the brand video thumbnail or a placeholder depending on source and availability.
*/
export const SegmentVideoFrame: React.FC<SegmentVideoFrameProps> = ({
scene,
designMD,
previewBrand,
aspectRatio,
onPositionChange,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const isIntro = scene.type === 'intro';
const isBrand = scene.segmentSource === 'brand';
// Current position (defaults to fullscreen centered)
const x = scene.segmentVideoX ?? 50;
const y = scene.segmentVideoY ?? 50;
const w = scene.segmentVideoW ?? 100;
const h = scene.segmentVideoH ?? 100;
const fit = scene.segmentVideoFit ?? (isBrand
? (isIntro ? (designMD.introVideoFit || 'cover') : (designMD.outroVideoFit || 'cover'))
: 'cover');
// Brand video URL
const videoUrl = isBrand
? (isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl)
: undefined;
const hasVideo = !!videoUrl;
const dimensions = getAspectDimensions(aspectRatio);
// ── Drag/resize hook ──
const {
startDrag,
startResize,
handlePointerMove,
handlePointerUp,
isDragging,
snapGuides,
} = useDragResize({
containerRef: containerRef as React.RefObject<HTMLElement>,
onMove: useCallback((_id: string, newX: number, newY: number) => {
onPositionChange({ segmentVideoX: newX, segmentVideoY: newY });
}, [onPositionChange]),
onResize: useCallback((_id: string, newW: number, newH: number) => {
onPositionChange({ segmentVideoW: newW, segmentVideoH: newH });
}, [onPositionChange]),
snapLines: [50],
snapThreshold: 1.5,
});
// Object-fit toggle
const fitOptions: Array<{ value: 'cover' | 'contain' | 'fill'; label: string }> = [
{ value: 'cover', label: 'Cover' },
{ value: 'contain', label: 'Contain' },
{ value: 'fill', label: 'Fill' },
];
// Background color based on scene type
const bgColor = useMemo(() => {
const bg = scene.background;
if (!bg) return designMD.secondaryColor;
switch (bg.type) {
case 'brand': return designMD.secondaryColor;
case 'solid': return bg.value || '#1a1a1a';
default: return designMD.secondaryColor;
}
}, [scene.background, designMD]);
return (
<div className="flex-1 flex flex-col items-center justify-center bg-neutral-950 p-4 overflow-hidden relative min-h-0">
{/* Dot pattern */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '30px 30px' }}
/>
{/* Mode indicator */}
<div className="absolute top-4 left-4 flex items-center gap-2 z-20">
<Film size={12} className="text-emerald-400" />
<span className="text-[9px] font-bold text-emerald-300 uppercase tracking-wider">
{isIntro ? 'Posición — Intro' : 'Posición — Outro'}
</span>
<span className={`text-[8px] px-1.5 py-0.5 rounded-full font-medium ${
isBrand ? 'bg-violet-500/15 text-violet-300' : 'bg-sky-500/15 text-sky-300'
}`}>
{isBrand ? '⚡ Marca' : '📋 Formulario'}
</span>
</div>
{/* Canvas wrapper */}
<div
ref={containerRef}
className="relative rounded-xl overflow-hidden shadow-2xl shadow-black/50 border border-neutral-800/40 select-none shrink-0"
style={{
...(aspectRatio === '9:16' || aspectRatio === '4:5'
? { height: 'calc(100% - 80px)', maxWidth: '90%' }
: {
width: aspectRatio === '1:1' ? 360 : 440,
maxHeight: 'calc(100% - 80px)',
}),
aspectRatio: `${dimensions.w} / ${dimensions.h}`,
backgroundColor: bgColor,
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Center crosshair (subtle) */}
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-white/[0.03] pointer-events-none z-0" />
<div className="absolute top-1/2 left-0 right-0 h-px bg-white/[0.03] pointer-events-none z-0" />
{/* Snap guides */}
{snapGuides.x !== undefined && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-50"
style={{ left: `${snapGuides.x}%`, width: '1px', background: 'rgba(16, 185, 129, 0.5)', borderLeft: '1px dashed rgba(16, 185, 129, 0.6)' }}
/>
)}
{snapGuides.y !== undefined && (
<div
className="absolute left-0 right-0 pointer-events-none z-50"
style={{ top: `${snapGuides.y}%`, height: '1px', background: 'rgba(16, 185, 129, 0.5)', borderTop: '1px dashed rgba(16, 185, 129, 0.6)' }}
/>
)}
{/* ── Video Frame Element ── */}
<div
className="absolute transition-shadow"
style={{
left: `${x - w / 2}%`,
top: `${y - h / 2}%`,
width: `${w}%`,
height: `${h}%`,
zIndex: 10,
}}
>
<div
className={`w-full h-full rounded-md flex flex-col items-center justify-center overflow-hidden cursor-grab active:cursor-grabbing transition-all ${
isDragging ? 'scale-[1.01] shadow-xl' : ''
}`}
style={{
border: '2px solid rgba(16, 185, 129, 0.6)',
outline: '2px solid rgba(16, 185, 129, 0.3)',
outlineOffset: '2px',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
}}
onPointerDown={(e) => {
e.stopPropagation();
startDrag(e, SEGMENT_VIDEO_ID, { x, y, w, h });
}}
>
{/* Video content */}
{isBrand && hasVideo ? (
<video
src={videoUrl}
muted
loop
autoPlay
playsInline
className="w-full h-full pointer-events-none"
style={{ objectFit: fit }}
/>
) : isBrand && !hasVideo ? (
<div className="flex flex-col items-center gap-2 pointer-events-none p-4">
<AlertTriangle size={20} className="text-amber-400" />
<span className="text-[9px] text-amber-300/80 text-center font-medium">
{previewBrand
? `${previewBrand.name} no tiene ${isIntro ? 'intro' : 'outro'}`
: `Sin ${isIntro ? 'intro' : 'outro'} de marca`}
</span>
<span className="text-[8px] text-neutral-500 text-center">
Puedes posicionar el marco ahora se aplicará cuando la marca tenga video
</span>
</div>
) : (
/* Form source */
<div className="flex flex-col items-center gap-2 pointer-events-none p-4">
<FileText size={20} className="text-sky-400" />
<span className="text-[9px] text-sky-300/80 text-center font-medium">
{scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre')}
</span>
<span className="text-[8px] text-neutral-500 text-center">
El productor subirá este video
</span>
</div>
)}
{/* Badge */}
<div
className="absolute -top-2.5 left-2 flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[7px] font-bold tracking-wider pointer-events-none"
style={{
backgroundColor: 'rgba(16, 185, 129, 0.15)',
color: '#6ee7b7',
border: '1px solid rgba(16, 185, 129, 0.3)',
}}
>
<Film size={7} /> {isIntro ? 'INTRO' : 'OUTRO'}
</div>
{/* Position readout */}
<div className="absolute -bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1 text-[7px] text-emerald-300/60 font-mono whitespace-nowrap pointer-events-none">
<Move size={7} /> {x.toFixed(0)},{y.toFixed(0)}
<Maximize2 size={7} className="ml-1" /> {w.toFixed(0)}×{h.toFixed(0)}
</div>
</div>
{/* Resize handle */}
<div
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 border-2 border-neutral-900 rounded-sm cursor-nwse-resize z-40 hover:opacity-80 transition-colors"
style={{ backgroundColor: '#10b981' }}
onPointerDown={(e) => startResize(e, SEGMENT_VIDEO_ID, { x, y, w, h })}
title="Redimensionar video"
/>
</div>
</div>
{/* Object-fit controls below canvas */}
<div className="mt-3 flex items-center gap-2 z-10">
<span className="text-[8px] text-neutral-500 font-mono uppercase tracking-wider">Ajuste:</span>
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
{fitOptions.map(opt => (
<button
key={opt.value}
onClick={() => onPositionChange({ segmentVideoFit: opt.value })}
title={`Ajuste de video: ${opt.label}`}
className={`px-2.5 py-1 rounded-md text-[8px] font-semibold transition-all ${
fit === opt.value
? 'bg-emerald-600/30 text-emerald-200 shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Reset button */}
<button
onClick={() => onPositionChange({
segmentVideoX: 50,
segmentVideoY: 50,
segmentVideoW: 100,
segmentVideoH: 100,
segmentVideoFit: 'cover',
})}
title="Restablecer posición a pantalla completa"
className="px-2 py-1 rounded-md text-[8px] text-neutral-500 hover:text-neutral-300 bg-neutral-800/40 border border-neutral-700/30 transition-colors"
>
Reset
</button>
</div>
</div>
);
};
@@ -0,0 +1,692 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
ArrowLeft, Save, Video, Image as ImageIcon,
Eye, FileText, Hash, Briefcase, FlaskConical, Zap,
} from 'lucide-react';
import {
ExpressTemplate, ExpressScene, DesignMD, CompanyProfile,
TemplateField, ExpressField,
} from '../../../types';
import {
TemplateBuilderProvider, useTemplateBuilder, useSceneFieldsMap,
TemplateMeta, migrateExpressFields,
} from '../../../context/TemplateBuilderContext';
import { FieldSchemaPanel } from './FieldSchemaPanel';
import { FieldConfigPanel } from './FieldConfigPanel';
import { BuilderCanvas } from './BuilderCanvas';
import { FormPreviewPanel } from './FormPreviewPanel';
import { SceneComposer } from './SceneComposer';
import { TemplateFieldInput } from '../../shared/TemplateFieldInput';
import { LivePreviewCanvas } from '../../shared/LivePreviewCanvas';
interface TemplateBuilderProps {
company?: CompanyProfile;
designMD?: DesignMD;
availableBrands?: CompanyProfile[];
onSave: (template: ExpressTemplate) => void;
onBack: () => void;
editingTemplate?: ExpressTemplate | null;
initialFormat?: 'video' | 'image';
initialAspect?: ExpressTemplate['aspectRatio'];
}
const CATEGORIES: { value: ExpressTemplate['category']; label: string; icon: string }[] = [
{ value: 'social', label: 'Social', icon: '📱' },
{ value: 'ad', label: 'Publicidad', icon: '🎯' },
{ value: 'promo', label: 'Promo', icon: '🚀' },
{ value: 'story', label: 'Historia', icon: '💬' },
{ value: 'announcement', label: 'Anuncio', icon: '📢' },
];
function createDefaultScene(format: 'video' | 'image'): ExpressScene {
const bgType = format === 'video' ? 'video' : 'image';
const bgLabel = format === 'video' ? 'Video de fondo' : 'Imagen de fondo';
const now = Date.now();
return {
id: `scene-${now}`,
type: 'content',
name: 'Nueva Escena',
durationSeconds: 5,
layout: 'overlay',
editableFields: [],
fields: [
// Background — always index 0 (bottom z-index)
{
id: `field-bg-${now}`,
nature: 'editable-slot' as const,
type: bgType,
label: bgLabel,
required: true,
content: bgLabel,
position: { x: 50, y: 50, w: 100, h: 100 },
style: { opacity: 100 },
formOrder: 0,
isBackground: true,
},
// Title — on top
{
id: `field-title-${now + 1}`,
nature: 'editable-slot' as const,
type: 'text' as const,
label: 'Título',
required: true,
content: 'Escribe aquí',
position: { x: 50, y: 45, w: 80, h: 15 },
style: { fontSize: 36, fontWeight: 700, textAlign: 'center' as const, opacity: 100 },
formOrder: 1,
},
],
background: { type: 'brand' },
transition: { type: 'fade', duration: 10 },
};
}
/**
* TemplateBuilder Redesigned visual template editor.
*
* Uses TemplateBuilderContext instead of EditorProvider.
* Layout: FieldSchemaPanel (left) | Canvas or FormPreview (center) | FieldConfigPanel (right)
*/
export const TemplateBuilder: React.FC<TemplateBuilderProps> = (props) => {
const format = props.editingTemplate?.format || props.initialFormat || 'video';
const initialScenes = useMemo(() => {
if (props.editingTemplate?.scenes?.length) return props.editingTemplate.scenes;
return [createDefaultScene(format)];
}, []);
const initialMeta: TemplateMeta = useMemo(() => ({
name: props.editingTemplate?.name || '',
description: props.editingTemplate?.description || '',
category: props.editingTemplate?.category || 'social',
aspectRatio: props.editingTemplate?.aspectRatio || props.initialAspect || '9:16',
format,
usesBrandAudio: props.editingTemplate?.usesBrandAudio ?? true,
}), []);
return (
<TemplateBuilderProvider
designMD={props.designMD}
company={props.company}
availableBrands={props.availableBrands}
initialScenes={initialScenes}
initialMeta={initialMeta}
>
<TemplateBuilderInner
onSave={props.onSave}
onBack={props.onBack}
editingTemplate={props.editingTemplate}
/>
</TemplateBuilderProvider>
);
};
/*
* Inner component lives inside TemplateBuilderProvider
* */
interface InnerProps {
onSave: (template: ExpressTemplate) => void;
onBack: () => void;
editingTemplate?: ExpressTemplate | null;
}
const TemplateBuilderInner: React.FC<InnerProps> = ({
onSave,
onBack,
editingTemplate,
}) => {
const {
scenes,
setScenes,
activeSceneId,
setActiveSceneId,
activeScene,
viewMode,
setViewMode,
templateMeta,
setTemplateMeta,
editableSlotCount,
totalFieldCount,
selectedFieldId,
previewBrand,
setPreviewBrand,
availableBrands,
resolvedDesignMD,
resolvedCompany,
fields,
testFieldData,
setTestFieldData,
testMediaFits,
setTestMediaFits,
testContainBgColors,
setTestContainBgColors,
// Segment management
addSegment,
removeSegment,
updateSegment,
introScene,
outroScene,
} = useTemplateBuilder();
const sceneFieldsMap = useSceneFieldsMap();
const [nameError, setNameError] = useState(false);
// ── Scene callbacks ──
const handleAddScene = useCallback(() => {
const newScene = createDefaultScene(templateMeta.format);
setScenes(prev => [...prev, newScene]);
setActiveSceneId(newScene.id);
}, [setScenes, setActiveSceneId, templateMeta.format]);
const handleRemoveScene = useCallback((sceneId: string) => {
setScenes(prev => {
const next = prev.filter(s => s.id !== sceneId);
if (activeSceneId === sceneId) {
setActiveSceneId(next[0]?.id || null);
}
return next;
});
}, [activeSceneId, setScenes, setActiveSceneId]);
const handleUpdateScene = useCallback((updated: ExpressScene) => {
setScenes(prev => prev.map(s => s.id === updated.id ? updated : s));
}, [setScenes]);
// ── Save ──
const handleSave = useCallback(() => {
if (!templateMeta.name.trim()) {
setNameError(true);
setTimeout(() => setNameError(false), 3000);
return;
}
// Convert all scene fields back to ExpressField format for backward compat
const updatedScenes = scenes.map(scene => {
const templateFields = sceneFieldsMap[scene.id] || [];
return {
...scene,
fields: templateFields,
editableFields: templateFieldsToExpressFields(templateFields),
};
});
const template: ExpressTemplate = {
id: editingTemplate?.id || `tpl-${Date.now()}`,
name: templateMeta.name,
description: templateMeta.description,
category: templateMeta.category,
icon: CATEGORIES.find(c => c.value === templateMeta.category)?.icon || '📐',
aspectRatio: templateMeta.aspectRatio,
format: templateMeta.format,
scenes: updatedScenes,
usesBrandAudio: templateMeta.format === 'video',
isCustom: true,
createdAt: editingTemplate?.createdAt || new Date().toISOString(),
};
onSave(template);
}, [templateMeta, scenes, sceneFieldsMap, editingTemplate, onSave]);
// ── Build a temporary ExpressTemplate from current state (for LivePreviewCanvas) ──
const buildCurrentTemplate = useCallback((): ExpressTemplate => {
const updatedScenes = scenes.map(scene => {
const templateFields = sceneFieldsMap[scene.id] || [];
return {
...scene,
fields: templateFields,
editableFields: templateFieldsToExpressFields(templateFields),
};
});
return {
id: editingTemplate?.id || 'tpl-preview',
name: templateMeta.name || 'Preview',
description: templateMeta.description,
category: templateMeta.category,
icon: CATEGORIES.find(c => c.value === templateMeta.category)?.icon || '📐',
aspectRatio: templateMeta.aspectRatio,
format: templateMeta.format,
scenes: updatedScenes,
usesBrandAudio: false,
isCustom: true,
};
}, [templateMeta, scenes, sceneFieldsMap, editingTemplate]);
return (
<div className="flex-1 flex overflow-hidden bg-neutral-950">
{/* ── Left: Field Schema Panel (full height) ── */}
<FieldSchemaPanel />
{/* ── Center: Canvas + Scene Composer ── */}
<div className="relative flex-1 flex flex-col min-h-0">
{/* Top bar — all metadata inline */}
<div className="h-11 flex items-center gap-2 px-3 border-b border-neutral-800/60 shrink-0 bg-neutral-950/80 backdrop-blur-sm z-10">
{/* Left: back */}
<button
onClick={onBack}
title="Volver a plantillas"
className="text-neutral-400 hover:text-white transition-colors shrink-0"
>
<ArrowLeft size={14} />
</button>
{/* Divider */}
<div className="w-px h-5 bg-neutral-800 shrink-0" />
{/* Name — inline editable */}
<input
type="text"
value={templateMeta.name}
onChange={(e) => { setTemplateMeta(prev => ({ ...prev, name: e.target.value })); setNameError(false); }}
placeholder="Nombre de plantilla..."
className={`bg-transparent border-none text-sm font-semibold text-white placeholder-neutral-600 focus:outline-none min-w-0 w-40 truncate transition-colors ${
nameError ? 'text-red-400 placeholder-red-500/50' : ''
}`}
/>
{/* Counter badge */}
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-sky-500/10 border border-sky-500/20 shrink-0">
<Hash size={8} className="text-sky-400" />
<span className="text-[8px] text-sky-300 font-mono">
{editableSlotCount}/{totalFieldCount}
</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Center: View mode toggle + aspect + format */}
<div className="flex items-center gap-2 shrink-0">
{/* View mode toggle */}
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
<button
onClick={() => setViewMode('design')}
title="Vista de diseño"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'design'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<Eye size={10} /> Diseño
</button>
<button
onClick={() => setViewMode('form-preview')}
title="Vista de formulario"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'form-preview'
? 'bg-neutral-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<FileText size={10} /> Formulario
</button>
<button
onClick={() => setViewMode('test-data')}
title="Probar con datos de ejemplo"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all ${
viewMode === 'test-data'
? 'bg-emerald-700 text-white shadow-sm'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
<FlaskConical size={10} /> Probar
</button>
</div>
{/* Brand preview selector */}
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
<Briefcase size={10} className={previewBrand ? 'text-violet-400 ml-1.5' : 'text-neutral-500 ml-1.5'} />
<select
value={previewBrand?.id ?? ''}
onChange={(e) => {
const brand = availableBrands.find(b => b.id === e.target.value) ?? null;
setPreviewBrand(brand);
}}
title="Ver con marca"
className="bg-transparent text-[9px] font-medium text-neutral-300 border-none focus:outline-none cursor-pointer px-1 py-1 appearance-none pr-4"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 4px center' }}
>
<option value="">Sin marca</option>
{availableBrands.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
{/* Aspect ratio */}
<span className="text-[10px] font-bold text-neutral-400">
{templateMeta.aspectRatio}
</span>
{/* Format badge */}
<div className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold ${
templateMeta.format === 'video'
? 'bg-violet-500/15 text-violet-300 border border-violet-500/20'
: 'bg-sky-500/15 text-sky-300 border border-sky-500/20'
}`}>
{templateMeta.format === 'video' ? <Video size={9} /> : <ImageIcon size={9} />}
{templateMeta.format === 'video' ? 'VIDEO' : 'IMG'}
</div>
</div>
{/* Divider */}
<div className="w-px h-5 bg-neutral-800 shrink-0" />
{/* Right: category + save */}
<div className="flex items-center gap-1.5 shrink-0">
{/* Category pills (compact) */}
{CATEGORIES.map(c => (
<button
key={c.value}
onClick={() => setTemplateMeta(prev => ({ ...prev, category: c.value }))}
title={c.label}
className={`px-1.5 py-0.5 rounded text-[8px] transition-all border ${
templateMeta.category === c.value
? 'bg-violet-600/15 border-violet-500/40 text-violet-300'
: 'bg-transparent border-transparent text-neutral-600 hover:text-neutral-400'
}`}
>
{c.icon}
</button>
))}
{/* Save button */}
<button
onClick={handleSave}
title={!templateMeta.name.trim() ? 'Dale un nombre primero' : 'Guardar plantilla'}
className={`flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-white text-[10px] font-semibold transition-all shadow-lg ${
!templateMeta.name.trim()
? 'bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 shadow-amber-900/30'
: 'bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 shadow-emerald-900/30'
}`}
>
<Save size={12} />
Guardar
</button>
</div>
</div>
{/* Canvas row: canvas + optional config panel */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Canvas / Form Preview / Test Data */}
<div className="flex-1 min-w-0 flex flex-col">
{viewMode === 'design' ? (
<BuilderCanvas />
) : viewMode === 'form-preview' ? (
<FormPreviewPanel />
) : (
/* test-data mode: split form + live preview */
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Test data form */}
<TestDataFormPanel />
{/* Live Remotion preview */}
<div className="flex-1 min-w-0 bg-neutral-950 relative">
{/* Subtle grid background */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
/>
<LivePreviewCanvas
template={buildCurrentTemplate()}
fieldData={testFieldData}
brand={resolvedCompany}
designMD={resolvedDesignMD}
mediaFits={testMediaFits}
containBgColors={testContainBgColors}
activeSceneId={activeSceneId}
onSceneChange={setActiveSceneId}
/>
</div>
</div>
)}
</div>
{/* Right: Field Config Panel (design/form-preview modes only, not in segment mode) */}
{selectedFieldId && viewMode !== 'test-data' && !activeScene?.segmentSource && (
<aside className="w-64 bg-neutral-900 border-l border-neutral-800/60 shrink-0 overflow-y-auto custom-scrollbar" onClick={(e) => e.stopPropagation()}>
<FieldConfigPanel />
</aside>
)}
</div>
{/* Scene Composer (video only) — always full width below canvas row */}
{templateMeta.format === 'video' && (
<div className="shrink-0 p-3 border-t border-neutral-800/60 bg-neutral-900/50">
<SceneComposer
scenes={scenes}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
onAddScene={handleAddScene}
onRemoveScene={handleRemoveScene}
designMD={resolvedDesignMD}
usesBrandAudio={templateMeta.usesBrandAudio}
format={templateMeta.format}
onAddSegment={addSegment}
onRemoveSegment={removeSegment}
onUpdateSegment={updateSegment}
previewBrand={previewBrand}
/>
</div>
)}
</div>
</div>
);
};
// ── Helper: Convert TemplateField[] to legacy ExpressField[] for backward compat ──
function templateFieldsToExpressFields(fields: TemplateField[]): ExpressField[] {
return fields.map((f): ExpressField => ({
id: f.id,
type: f.type === 'video' ? 'media' : f.type === 'image' ? (f.brandSource === 'logo' ? 'logo' : 'media') : f.type === 'shape' ? 'shape' : 'text',
label: f.label,
placeholder: f.content || f.label,
required: f.required,
brandSource: f.brandSource,
brandAssetId: f.brandAssetId,
position: { x: f.position.x, y: f.position.y, w: f.position.w, h: f.position.h },
style: {
fontSize: f.style.fontSize,
fontWeight: f.style.fontWeight,
fontFamily: f.style.fontFamily,
textAlign: f.style.textAlign,
color: f.style.color,
opacity: f.style.opacity,
shapeType: f.style.shapeType,
shapeFill: f.style.shapeFill,
shapeStroke: f.style.shapeStroke,
shapeStrokeWidth: f.style.shapeStrokeWidth,
shapeCornerRadius: f.style.shapeCornerRadius,
},
}));
}
// ── TestDataFormPanel — Form for entering test data in test-data view mode ──
/** Resolve brand variable preview for read-only display */
function resolveBrandTestValue(field: TemplateField, company: CompanyProfile, designMD: DesignMD): string {
if (!field.brandSource) return '';
switch (field.brandSource) {
case 'brand-name': return company.name || designMD.brandName || '';
case 'tagline': return company.tagline || '';
case 'logo': return '(Logo de marca)';
case 'instagram': return company.socialLinks?.instagram || '';
case 'tiktok': return company.socialLinks?.tiktok || '';
case 'twitter': return company.socialLinks?.x || '';
case 'youtube': return company.socialLinks?.youtube || '';
case 'website': return company.socialLinks?.website || '';
default: return '';
}
}
const TestDataFormPanel: React.FC = () => {
const {
fields,
scenes,
activeSceneId,
setActiveSceneId,
resolvedDesignMD: designMD,
resolvedCompany: company,
testFieldData,
setTestFieldData,
testMediaFits,
setTestMediaFits,
testContainBgColors,
setTestContainBgColors,
} = useTemplateBuilder();
const sceneFieldsMap = useSceneFieldsMap();
// Get all editable slots across all scenes
const allEditableSlots = useMemo(() => {
const slots: { field: TemplateField; sceneId: string; sceneName: string }[] = [];
for (const scene of scenes) {
const sceneFields = sceneFieldsMap[scene.id] || [];
for (const f of sceneFields) {
if (f.nature === 'editable-slot') {
slots.push({ field: f, sceneId: scene.id, sceneName: scene.name });
}
}
}
return slots.sort((a, b) => a.field.formOrder - b.field.formOrder);
}, [scenes, sceneFieldsMap]);
const brandVars = useMemo(() => {
const vars: TemplateField[] = [];
for (const scene of scenes) {
const sceneFields = sceneFieldsMap[scene.id] || [];
for (const f of sceneFields) {
if (f.nature === 'brand-variable') vars.push(f);
}
}
return vars;
}, [scenes, sceneFieldsMap]);
// Group by scene
const sceneGroups = useMemo(() => {
const groups: { sceneId: string; sceneName: string; fields: typeof allEditableSlots }[] = [];
const seen = new Set<string>();
for (const slot of allEditableSlots) {
if (!seen.has(slot.sceneId)) {
seen.add(slot.sceneId);
groups.push({
sceneId: slot.sceneId,
sceneName: slot.sceneName,
fields: allEditableSlots.filter(s => s.sceneId === slot.sceneId),
});
}
}
return groups;
}, [allEditableSlots]);
const isMultiScene = sceneGroups.length > 1;
return (
<div className="w-[360px] shrink-0 flex flex-col border-r border-neutral-800/60 bg-neutral-950/95 backdrop-blur-sm">
{/* Header */}
<div className="px-4 py-3 border-b border-neutral-800/30 bg-gradient-to-r from-emerald-500/5 to-teal-500/5 shrink-0">
<div className="flex items-center gap-2">
<FlaskConical size={13} className="text-emerald-400" />
<h2 className="text-xs font-bold text-white">Datos de prueba</h2>
<span className="text-[9px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full font-medium">
{allEditableSlots.length} campo{allEditableSlots.length !== 1 ? 's' : ''}
</span>
</div>
<p className="text-[10px] text-neutral-500 mt-1">
Llena los campos para ver cómo se vería tu plantilla con datos reales.
</p>
</div>
{/* Scrollable fields */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 py-4 space-y-4">
{allEditableSlots.length === 0 ? (
<div className="text-center py-8">
<FlaskConical size={24} className="text-neutral-700 mx-auto mb-2" />
<p className="text-xs text-neutral-500">No hay campos editables para probar.</p>
</div>
) : isMultiScene ? (
sceneGroups.map(group => (
<div key={group.sceneId} className="space-y-3">
<button
onClick={() => setActiveSceneId(group.sceneId)}
title={`Ir a escena: ${group.sceneName}`}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-all ${
activeSceneId === group.sceneId
? 'border-emerald-500/30 bg-emerald-500/5'
: 'border-neutral-800/50 bg-neutral-900/30 hover:border-neutral-700'
}`}
>
<div className={`w-2 h-2 rounded-full shrink-0 ${
activeSceneId === group.sceneId ? 'bg-emerald-500' : 'bg-neutral-600'
}`} />
<span className="text-[11px] font-semibold text-white flex-1">{group.sceneName}</span>
<span className="text-[9px] text-neutral-500">{group.fields.length} campo{group.fields.length !== 1 ? 's' : ''}</span>
</button>
{group.fields.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={testFieldData[field.id] || ''}
onChange={(v) => setTestFieldData(prev => ({ ...prev, [field.id]: v }))}
designMD={designMD}
mediaFit={testMediaFits[field.id]}
onMediaFitChange={(fit) => setTestMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={testContainBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setTestContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))}
</div>
))
) : (
allEditableSlots.map(({ field }) => (
<TemplateFieldInput
key={field.id}
field={field}
value={testFieldData[field.id] || ''}
onChange={(v) => setTestFieldData(prev => ({ ...prev, [field.id]: v }))}
designMD={designMD}
mediaFit={testMediaFits[field.id]}
onMediaFitChange={(fit) => setTestMediaFits(prev => ({ ...prev, [field.id]: fit }))}
containBgColor={testContainBgColors[field.id] ?? null}
onContainBgColorChange={(color) => setTestContainBgColors(prev => ({ ...prev, [field.id]: color }))}
/>
))
)}
{/* Brand variables (read-only) */}
{brandVars.length > 0 && (
<div className="pt-4 border-t border-neutral-800/50">
<p className="text-[9px] text-violet-400 uppercase tracking-wider font-semibold mb-3 flex items-center gap-1">
<Zap size={8} /> Auto-completados desde {company.name}
</p>
<div className="space-y-2">
{brandVars.map(field => (
<div
key={field.id}
className="flex items-center gap-3 px-3 py-2.5 bg-violet-500/5 border border-violet-500/15 rounded-lg"
>
<Zap size={10} className="text-violet-400 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[10px] text-violet-300 font-medium">{field.label}</span>
<span className="text-[9px] text-violet-400/50 block truncate">
{resolveBrandTestValue(field, company, designMD) || '(no configurado)'}
</span>
</div>
<span className="text-[7px] text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded font-bold shrink-0">
auto
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
+227
View File
@@ -0,0 +1,227 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Music, Play, Pause, Clock, Loader2 } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { FileDropZone } from '../ui/FileDropZone';
import { uploadMedia } from '../../utils/mediaUploader';
import { useAudioPreview } from '../../hooks/useAudioPreview';
import { getAudioDuration, formatDuration } from '../../utils/audioMetadata';
import { AudioWaveformCanvas } from '../timeline/AudioWaveformCanvas';
interface AudioPanelProps {
onClose: () => void;
}
interface AudioItem {
src: string;
name: string;
duration: number | null;
}
/**
* Panel for adding audio files. Draggable to timeline.
* Auto-routes to audio layers. Supports preview and waveform.
*/
export const AudioPanel: React.FC<AudioPanelProps> = ({ onClose }) => {
const { designMD } = useEditor();
const [localAudios, setLocalAudios] = useState<AudioItem[]>([]);
const [brandDuration, setBrandDuration] = useState<number | null>(null);
const preview = useAudioPreview();
const [playingIdx, setPlayingIdx] = useState<number | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Load brand audio duration
useEffect(() => {
if (designMD.brandAudioUrl) {
getAudioDuration(designMD.brandAudioUrl).then(d => setBrandDuration(d));
}
}, [designMD.brandAudioUrl]);
const handleUpload = useCallback(async (files: File[]) => {
const audioFiles = files.filter(f => f.type.startsWith('audio/'));
if (audioFiles.length === 0) return;
setIsUploading(true);
try {
const items: AudioItem[] = [];
for (const file of audioFiles) {
const result = await uploadMedia(file);
let duration: number | null = null;
try {
duration = await getAudioDuration(result.url);
} catch {}
items.push({ src: result.url, name: result.originalName, duration });
}
setLocalAudios(prev => [...items, ...prev]);
} catch (err) {
console.error('Audio upload failed:', err);
} finally {
setIsUploading(false);
}
}, []);
const handleTogglePreview = useCallback((src: string, idx: number) => {
if (playingIdx === idx) {
preview.pause();
setPlayingIdx(null);
} else {
preview.setSrc(src);
preview.play();
setPlayingIdx(idx);
}
}, [playingIdx, preview]);
// Stop preview when panel closes
useEffect(() => {
return () => {
preview.pause();
};
}, []);
const allAudios = [...localAudios];
const hasBrandAudio = !!designMD.brandAudioUrl;
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Audio
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* Upload */}
<FileDropZone
accept="audio/*"
multiple
onFiles={handleUpload}
label={isUploading ? 'Subiendo...' : "Subir audio"}
sublabel={isUploading ? undefined : "MP3, WAV, OGG"}
/>
{isUploading && (
<div className="flex items-center justify-center gap-2 py-2 text-violet-400">
<Loader2 size={14} className="animate-spin" />
<span className="text-[10px] font-medium">Subiendo al servidor...</span>
</div>
)}
{/* Brand Audio */}
{hasBrandAudio && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Audio de Marca</span>
<div
className="flex flex-col gap-2 p-2.5 bg-neutral-800/50 border border-neutral-800 rounded-lg cursor-grab active:cursor-grabbing hover:border-violet-500/40 transition-colors group"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', designMD.brandAudioUrl!);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'audio', src: designMD.brandAudioUrl }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div className="flex items-center gap-3">
<button
onClick={(e) => { e.stopPropagation(); handleTogglePreview(designMD.brandAudioUrl!, -1); }}
className="w-8 h-8 rounded-md bg-violet-600/20 border border-violet-500/30 flex items-center justify-center shrink-0 hover:bg-violet-600/40 transition-colors"
title={playingIdx === -1 ? "Pausar Preview" : "Escuchar Preview"}
>
{playingIdx === -1 ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-violet-400 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-white block truncate">Jingle de Marca</span>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-500">Arrastrar al timeline</span>
{brandDuration !== null && (
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
<Clock size={8} /> {formatDuration(brandDuration)}
</span>
)}
</div>
</div>
</div>
{/* Mini Waveform */}
<div className="bg-neutral-900/50 rounded overflow-hidden">
<AudioWaveformCanvas
src={designMD.brandAudioUrl!}
width={220}
height={24}
color="rgba(139, 92, 246, 0.4)"
resolution={100}
/>
</div>
</div>
</div>
)}
{/* Uploaded Audios */}
{localAudios.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Mis Audios</span>
<div className="space-y-2">
{localAudios.map((audio, i) => (
<div
key={`audio-${i}`}
className="flex flex-col gap-2 p-2.5 bg-neutral-950/50 border border-neutral-800/60 rounded-lg cursor-grab active:cursor-grabbing hover:border-neutral-700 transition-colors group"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', audio.src);
e.dataTransfer.setData('application/json', JSON.stringify({
type: 'audio',
src: audio.src,
fileName: audio.name,
}));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div className="flex items-center gap-3">
<button
onClick={(e) => { e.stopPropagation(); handleTogglePreview(audio.src, i); }}
className="w-8 h-8 rounded-md bg-neutral-800 flex items-center justify-center shrink-0 hover:bg-neutral-700 transition-colors"
title={playingIdx === i ? "Pausar Preview" : "Escuchar Preview"}
>
{playingIdx === i ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-neutral-400 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate" title={audio.name}>{audio.name}</span>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-neutral-600">Arrastrar al timeline</span>
{audio.duration !== null && (
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
<Clock size={8} /> {formatDuration(audio.duration)}
</span>
)}
</div>
</div>
</div>
{/* Mini Waveform */}
<div className="bg-neutral-900/30 rounded overflow-hidden">
<AudioWaveformCanvas
src={audio.src}
width={220}
height={20}
color="rgba(129, 140, 248, 0.35)"
resolution={80}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state */}
{!hasBrandAudio && localAudios.length === 0 && (
<div className="text-center py-6 text-neutral-500">
<Music size={28} className="mx-auto mb-2 opacity-40" />
<p className="text-xs font-medium">Sin audio disponible</p>
<p className="text-[10px] mt-1">Sube archivos de audio o configura el jingle de marca</p>
</div>
)}
</div>
</div>
);
};
+163
View File
@@ -0,0 +1,163 @@
import React, { useCallback } from 'react';
import { X, Hexagon } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface ShapesPanelProps {
onClose: () => void;
}
interface ShapeDef {
type: TimelineElement['shapeType'];
label: string;
svg: React.ReactNode;
}
const SHAPES: ShapeDef[] = [
{
type: 'rectangle',
label: 'Rectángulo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<rect x="4" y="8" width="40" height="32" rx="3" fill="currentColor" />
</svg>
),
},
{
type: 'circle',
label: 'Círculo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<circle cx="24" cy="24" r="20" fill="currentColor" />
</svg>
),
},
{
type: 'triangle',
label: 'Triángulo',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<polygon points="24,4 44,44 4,44" fill="currentColor" />
</svg>
),
},
{
type: 'star',
label: 'Estrella',
svg: (
<svg viewBox="0 0 48 48" className="w-10 h-10">
<polygon points="24,2 29,17 46,17 33,27 38,44 24,34 10,44 15,27 2,17 19,17" fill="currentColor" />
</svg>
),
},
{
type: 'line',
label: 'Línea',
svg: (
<svg viewBox="0 0 48 12" className="w-10 h-4">
<line x1="4" y1="6" x2="44" y2="6" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
),
},
{
type: 'arrow',
label: 'Flecha',
svg: (
<svg viewBox="0 0 48 24" className="w-10 h-5">
<line x1="4" y1="12" x2="36" y2="12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<polygon points="32,4 46,12 32,20" fill="currentColor" />
</svg>
),
},
];
/**
* ShapesPanel Grid of basic shapes to insert into the canvas.
*/
export const ShapesPanel: React.FC<ShapesPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const addShape = useCallback((shapeDef: ShapeDef) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
// Find or create a visual layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'shape',
content: shapeDef.label,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 35,
y: 35,
width: 30,
shapeType: shapeDef.type,
shapeFill: '#ffffff',
shapeStroke: 'none',
shapeStrokeWidth: 0,
shapeCornerRadius: 0,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
return (
<div className="w-72 bg-neutral-950 border-r border-neutral-800 flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800 shrink-0">
<div className="flex items-center gap-2">
<Hexagon size={16} className="text-violet-400" />
<h3 className="text-sm font-bold text-white">Formas</h3>
</div>
<button
onClick={onClose}
title="Cerrar panel"
className="p-1 rounded hover:bg-neutral-800 text-neutral-500 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Shapes Grid */}
<div className="p-4 flex-1 overflow-y-auto custom-scrollbar">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-3">Básicas</label>
<div className="grid grid-cols-3 gap-2">
{SHAPES.map((shape) => (
<button
key={shape.type}
onClick={() => addShape(shape)}
title={`Insertar ${shape.label}`}
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-xl border border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:text-violet-400 hover:border-violet-500/40 hover:bg-violet-500/5 transition-all group"
>
<div className="text-neutral-500 group-hover:text-violet-400 transition-colors">
{shape.svg}
</div>
<span className="text-[9px] font-medium">{shape.label}</span>
</button>
))}
</div>
</div>
</div>
);
};
+407
View File
@@ -0,0 +1,407 @@
import React, { useState, useCallback } from 'react';
import { Search, Volume2, Plus, Loader2 } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface SfxCategory {
name: string;
emoji: string;
effects: SoundEffect[];
}
interface SoundEffect {
name: string;
description: string;
durationSec: number;
// These would be actual URLs in production — for now they are placeholders
// that get generated/loaded on demand
generator: 'tone' | 'noise' | 'click';
frequency?: number;
}
const SFX_CATEGORIES: SfxCategory[] = [
{
name: 'Transiciones',
emoji: '🔄',
effects: [
{ name: 'Whoosh', description: 'Paso rápido', durationSec: 0.8, generator: 'noise' },
{ name: 'Swoosh Suave', description: 'Movimiento suave', durationSec: 0.6, generator: 'noise' },
{ name: 'Click', description: 'Click mecánico', durationSec: 0.2, generator: 'click' },
{ name: 'Pop', description: 'Aparición', durationSec: 0.3, generator: 'click', frequency: 800 },
],
},
{
name: 'UI / Notificaciones',
emoji: '🔔',
effects: [
{ name: 'Ding', description: 'Notificación', durationSec: 0.5, generator: 'tone', frequency: 880 },
{ name: 'Beep', description: 'Alerta simple', durationSec: 0.3, generator: 'tone', frequency: 440 },
{ name: 'Error', description: 'Error/rechazo', durationSec: 0.4, generator: 'tone', frequency: 220 },
{ name: 'Success', description: 'Éxito/aprobado', durationSec: 0.6, generator: 'tone', frequency: 660 },
],
},
{
name: 'Impacto',
emoji: '💥',
effects: [
{ name: 'Boom', description: 'Impacto bajo', durationSec: 1.0, generator: 'noise' },
{ name: 'Hit Suave', description: 'Golpe leve', durationSec: 0.4, generator: 'noise' },
{ name: 'Drum Hit', description: 'Tambor', durationSec: 0.5, generator: 'tone', frequency: 80 },
],
},
{
name: 'Ambientes',
emoji: '🌊',
effects: [
{ name: 'Lluvia', description: 'Sonido de lluvia', durationSec: 3.0, generator: 'noise' },
{ name: 'Viento', description: 'Brisa suave', durationSec: 3.0, generator: 'noise' },
{ name: 'Estática', description: 'Ruido blanco', durationSec: 2.0, generator: 'noise' },
],
},
];
/**
* Generate a simple sound effect using Web Audio API and return as blob URL.
*/
function generateSfx(effect: SoundEffect): string {
const ctx = new OfflineAudioContext(1, 44100 * effect.durationSec, 44100);
if (effect.generator === 'tone') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = effect.frequency ?? 440;
gain.gain.setValueAtTime(0.5, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(effect.durationSec);
} else if (effect.generator === 'click') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = effect.frequency ?? 1000;
gain.gain.setValueAtTime(0.8, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(effect.durationSec);
} else {
// Noise generator
const bufferSize = ctx.sampleRate * effect.durationSec;
const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = noiseBuffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, 0);
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.9);
source.connect(gain);
gain.connect(ctx.destination);
source.start(0);
}
// OfflineAudioContext renders synchronously in terms of API but returns a promise
// We'll create a placeholder and update async
return ''; // Will be replaced
}
/**
* Generate SFX and return a blob URL asynchronously.
*/
async function generateSfxAsync(effect: SoundEffect): Promise<string> {
const sampleRate = 44100;
const duration = effect.durationSec;
const ctx = new OfflineAudioContext(1, sampleRate * duration, sampleRate);
if (effect.generator === 'tone') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = effect.frequency ?? 440;
gain.gain.setValueAtTime(0.5, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(duration);
} else if (effect.generator === 'click') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = effect.frequency ?? 1000;
gain.gain.setValueAtTime(0.8, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.3);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(0);
osc.stop(duration);
} else {
const bufferSize = sampleRate * duration;
const noiseBuffer = ctx.createBuffer(1, bufferSize, sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = noiseBuffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(0.3, 0);
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.9);
source.connect(gain);
gain.connect(ctx.destination);
source.start(0);
}
const rendered = await ctx.startRendering();
// Convert to WAV blob
const numChannels = 1;
const length = rendered.length * numChannels * 2 + 44;
const buffer = new ArrayBuffer(length);
const view = new DataView(buffer);
// WAV header
const writeString = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
};
writeString(0, 'RIFF');
view.setUint32(4, length - 8, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * numChannels * 2, true);
view.setUint16(32, numChannels * 2, true);
view.setUint16(34, 16, true);
writeString(36, 'data');
view.setUint32(40, rendered.length * numChannels * 2, true);
const channelData = rendered.getChannelData(0);
let offset = 44;
for (let i = 0; i < rendered.length; i++) {
const sample = Math.max(-1, Math.min(1, channelData[i]));
view.setInt16(offset, sample * 0x7FFF, true);
offset += 2;
}
const blob = new Blob([buffer], { type: 'audio/wav' });
return URL.createObjectURL(blob);
}
interface SoundEffectsPanelProps {
onClose: () => void;
}
/**
* SoundEffectsPanel Categorized SFX library with Web Audio generated effects.
*/
export const SoundEffectsPanel: React.FC<SoundEffectsPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const [search, setSearch] = useState('');
const [previewingId, setPreviewingId] = useState<string | null>(null);
const [insertingId, setInsertingId] = useState<string | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>(
Object.fromEntries(SFX_CATEGORIES.map(c => [c.name, true]))
);
// Preview a sound effect
const handlePreview = useCallback(async (effect: SoundEffect) => {
const id = effect.name;
setPreviewingId(id);
try {
const url = await generateSfxAsync(effect);
const audio = new Audio(url);
audio.volume = 0.5;
audio.play();
audio.onended = () => {
setPreviewingId(null);
URL.revokeObjectURL(url);
};
} catch {
setPreviewingId(null);
}
}, []);
// Insert into timeline
const handleInsert = useCallback(async (effect: SoundEffect) => {
const id = effect.name;
setInsertingId(id);
try {
const url = await generateSfxAsync(effect);
// Upload to server for persistence
const response = await fetch(url);
const blob = await response.blob();
const file = new File([blob], `sfx-${effect.name.toLowerCase().replace(/\s+/g, '-')}.wav`, { type: 'audio/wav' });
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
let persistentUrl = url;
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
persistentUrl = uploadData.url;
URL.revokeObjectURL(url);
}
// Find or create audio layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type !== 'audio') {
let audioLayer = layers.find(l => l.type === 'audio');
if (!audioLayer) {
audioLayer = { id: 'layer-audio-' + Date.now(), name: 'Audio', type: 'audio' };
setLayers(prev => [...prev, audioLayer!]);
}
targetLayerId = audioLayer.id;
setActiveLayerId(targetLayerId);
}
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newElement: TimelineElement = {
id: 'sfx-' + Date.now(),
layerId: targetLayerId,
type: 'audio',
content: persistentUrl,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + Math.round(effect.durationSec * 30)),
x: 0,
y: 0,
originalFileName: `SFX: ${effect.name}`,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newElement.id);
} catch (err) {
console.error('SFX insert error:', err);
} finally {
setInsertingId(null);
}
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
// Filter
const filteredCategories = SFX_CATEGORIES
.map(cat => ({
...cat,
effects: cat.effects.filter(e =>
!search || e.name.toLowerCase().includes(search.toLowerCase()) || e.description.toLowerCase().includes(search.toLowerCase())
),
}))
.filter(cat => cat.effects.length > 0);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Volume2 size={14} className="text-emerald-400" />
Efectos de Sonido
</h3>
<button onClick={onClose} title="Cerrar" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
</button>
</div>
{/* Search */}
<div className="p-3 border-b border-neutral-800/50">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar efectos..."
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-emerald-500/50"
/>
</div>
</div>
{/* Categories */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
{filteredCategories.map((cat) => (
<div key={cat.name}>
<button
onClick={() => setExpandedCategories(prev => ({ ...prev, [cat.name]: !prev[cat.name] }))}
title={`Categoría ${cat.name}`}
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/50 transition-colors text-left"
>
<span className="text-sm">{cat.emoji}</span>
<span className="text-[11px] font-semibold text-neutral-300 flex-1">{cat.name}</span>
<span className="text-[9px] text-neutral-600">{cat.effects.length}</span>
</button>
{expandedCategories[cat.name] && (
<div className="ml-1 space-y-0.5 mt-0.5">
{cat.effects.map((effect) => {
const effectId = effect.name;
const isPreviewing = previewingId === effectId;
const isInserting = insertingId === effectId;
return (
<div
key={effectId}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/40 transition-colors group"
>
{/* Preview button */}
<button
onClick={() => handlePreview(effect)}
disabled={isPreviewing}
title={`Previsualizar ${effect.name}`}
className={`p-1 rounded transition-colors ${
isPreviewing
? 'text-emerald-400 animate-pulse'
: 'text-neutral-600 hover:text-emerald-400'
}`}
>
<Volume2 size={12} />
</button>
{/* Name + Desc */}
<div className="flex-1 min-w-0">
<div className="text-[10px] font-medium text-neutral-300 truncate">{effect.name}</div>
<div className="text-[8px] text-neutral-600 truncate">{effect.description} · {effect.durationSec}s</div>
</div>
{/* Insert button */}
<button
onClick={() => handleInsert(effect)}
disabled={isInserting}
title={`Insertar ${effect.name}`}
className="p-1 rounded text-neutral-600 hover:text-emerald-400 opacity-0 group-hover:opacity-100 transition-all"
>
{isInserting ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
</button>
</div>
);
})}
</div>
)}
</div>
))}
{filteredCategories.length === 0 && (
<div className="text-center py-6 text-neutral-600 text-xs">
<Volume2 size={24} className="mx-auto mb-2 opacity-30" />
<p>No se encontraron efectos</p>
</div>
)}
</div>
</div>
);
};
+235
View File
@@ -0,0 +1,235 @@
import React, { useCallback } from 'react';
import { X, Stamp, Image as ImageIcon, Type, AtSign, Globe, Instagram } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { FileDropZone } from '../ui/FileDropZone';
import { TimelineElement } from '../../types';
interface StickersPanelProps {
onClose: () => void;
}
/**
* Panel for brand assets: branded text presets, social handles, stickers.
* Text presets use the brand font, color, and name from designMD.
*/
export const StickersPanel: React.FC<StickersPanelProps> = ({ onClose }) => {
const {
designMD, brandContent,
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const brandName = designMD.brandName || 'Mi Marca';
const font = designMD.baseFont || 'system-ui';
const color = designMD.textColor || '#ffffff';
const social = designMD.socialHandles || {};
const brandContentThumbnails = (brandContent || [])
.filter(p => p.thumbnail)
.map(p => ({ src: p.thumbnail!, name: p.name, id: p.id }));
const legacyStickers = designMD.brandStickers || [];
// Add branded text element to a visual layer
const addBrandText = useCallback((content: string, fontSize?: number, y?: number) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'text',
content,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 50,
y: y ?? 50,
fontSize,
fontFamily: font,
color: color,
useBranding: true,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId, font, color]);
// Build social text presets
const socialPresets: { label: string; content: string; icon: React.ReactNode }[] = [];
if (social.instagram) socialPresets.push({ label: 'Instagram', content: social.instagram, icon: <Instagram size={12} /> });
if (social.tiktok) socialPresets.push({ label: 'TikTok', content: social.tiktok, icon: <AtSign size={12} /> });
if (social.twitter) socialPresets.push({ label: 'Twitter/X', content: social.twitter, icon: <AtSign size={12} /> });
if (social.youtube) socialPresets.push({ label: 'YouTube', content: social.youtube, icon: <AtSign size={12} /> });
if (social.website) socialPresets.push({ label: 'Web', content: social.website, icon: <Globe size={12} /> });
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Stamp size={14} className="text-amber-400" />
Marca
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* ═══ Textos de Marca ═══ */}
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Textos de Marca</span>
<div className="space-y-1.5">
{/* Brand name — large */}
<button
onClick={() => addBrandText(brandName, 64, 40)}
title={`Añadir "${brandName}" como título`}
className="w-full flex items-center gap-2.5 px-3 py-2.5 bg-neutral-950/60 border border-amber-900/30 rounded-lg text-left hover:border-amber-500/40 hover:bg-amber-950/20 transition-all group"
>
<div className="w-8 h-8 rounded-md bg-amber-600/15 border border-amber-500/30 flex items-center justify-center shrink-0">
<Type size={14} className="text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-bold text-white block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Título grande · 64px</span>
</div>
</button>
{/* Brand name — subtitle */}
<button
onClick={() => addBrandText(brandName, 36, 50)}
title={`Añadir "${brandName}" como subtítulo`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
<Type size={12} className="text-neutral-400 group-hover:text-amber-400 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Subtítulo · 36px</span>
</div>
</button>
{/* Brand name — small watermark */}
<button
onClick={() => addBrandText(brandName, 20, 90)}
title={`Añadir "${brandName}" como marca de agua`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
<Type size={10} className="text-neutral-500 group-hover:text-amber-400 transition-colors" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[10px] text-neutral-400 block truncate" style={{ fontFamily: font }}>{brandName}</span>
<span className="text-[9px] text-neutral-600">Marca de agua · 20px</span>
</div>
</button>
</div>
</div>
{/* ═══ Redes Sociales ═══ */}
{socialPresets.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Redes Sociales</span>
<div className="space-y-1.5">
{socialPresets.map((sp) => (
<button
key={sp.label}
onClick={() => addBrandText(sp.content, 28, 85)}
title={`Añadir ${sp.label}: ${sp.content}`}
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-violet-500/30 hover:bg-violet-950/10 transition-all group"
>
<div className="w-7 h-7 rounded-md bg-violet-600/15 border border-violet-500/30 flex items-center justify-center shrink-0 text-violet-400">
{sp.icon}
</div>
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-neutral-300 block truncate">{sp.content}</span>
<span className="text-[9px] text-neutral-600">{sp.label} · 28px</span>
</div>
</button>
))}
</div>
</div>
)}
{/* ═══ Contenido Visual de Marca ═══ */}
{brandContentThumbnails.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Contenido Visual</span>
<div className="grid grid-cols-2 gap-2">
{brandContentThumbnails.map(item => (
<div
key={item.id}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', item.src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src: item.src, brandContentId: item.id }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<img src={item.src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt={item.name} draggable={false} />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center pointer-events-none">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
<span className="text-[8px] text-neutral-300 mt-0.5">{item.name}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Legacy Stickers */}
{legacyStickers.length > 0 && (
<div>
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Stickers</span>
<div className="grid grid-cols-2 gap-2">
{legacyStickers.map((src, i) => (
<div
key={`sticker-${i}`}
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', src);
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<img src={src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt="Sticker" draggable={false} />
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Upload */}
<FileDropZone
accept="image/*"
multiple
onFiles={() => {}}
label="Subir assets de marca"
sublabel="PNG con transparencia"
/>
</div>
</div>
);
};
+266
View File
@@ -0,0 +1,266 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Search, Film, Image as ImageIcon, Loader2, Download, ExternalLink } from 'lucide-react';
import { searchStockPhotos, searchStockVideos, downloadStockToServer, StockPhoto, StockVideo } from '../../utils/stockMediaApi';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
type MediaType = 'photos' | 'videos';
/**
* StockMediaTab Search and insert stock photos/videos from Pexels.
*/
export const StockMediaTab: React.FC = () => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const [query, setQuery] = useState('');
const [mediaType, setMediaType] = useState<MediaType>('photos');
const [photos, setPhotos] = useState<StockPhoto[]>([]);
const [videos, setVideos] = useState<StockVideo[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isDownloading, setIsDownloading] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
// ─── Search ───
const doSearch = useCallback(async (q: string, p: number, type: MediaType, append = false) => {
setIsLoading(true);
try {
if (type === 'photos') {
const result = await searchStockPhotos(q || 'trending', p);
setPhotos(prev => append ? [...prev, ...result.items] : result.items);
setHasMore(result.hasMore);
} else {
const result = await searchStockVideos(q || 'trending', p);
setVideos(prev => append ? [...prev, ...result.items] : result.items);
setHasMore(result.hasMore);
}
} finally {
setIsLoading(false);
}
}, []);
// Debounced search on query change
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
doSearch(query, 1, mediaType);
}, 500);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [query, mediaType, doSearch]);
// Load more (infinite scroll)
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasMore && !isLoading) {
const nextPage = page + 1;
setPage(nextPage);
doSearch(query, nextPage, mediaType, true);
}
}, { threshold: 0.5 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, isLoading, page, query, mediaType, doSearch]);
// ─── Insert into canvas ───
const insertPhoto = useCallback(async (photo: StockPhoto) => {
setIsDownloading(photo.id);
try {
// Download to server for persistence
const persistentUrl = await downloadStockToServer(photo.mediumUrl, `pexels-${photo.id}.jpg`);
insertElement(persistentUrl, 'image');
} catch {
// Fallback: use direct URL
insertElement(photo.mediumUrl, 'image');
} finally {
setIsDownloading(null);
}
}, []);
const insertVideo = useCallback(async (video: StockVideo) => {
setIsDownloading(video.id);
try {
const persistentUrl = await downloadStockToServer(video.videoUrl, `pexels-${video.id}.mp4`);
insertElement(persistentUrl, 'video');
} catch {
insertElement(video.videoUrl, 'video');
} finally {
setIsDownloading(null);
}
}, []);
const insertElement = useCallback((src: string, type: 'image' | 'video') => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Visual', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type,
content: src,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + (type === 'video' ? 150 : 100)),
x: 25,
y: 25,
width: 50,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
const items = mediaType === 'photos' ? photos : videos;
return (
<div className="flex flex-col h-full">
{/* Search + Type Toggle */}
<div className="p-3 space-y-2 border-b border-neutral-800/50">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar en Pexels..."
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-violet-500/50"
/>
</div>
<div className="flex gap-1">
<button
onClick={() => setMediaType('photos')}
title="Buscar fotos"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
mediaType === 'photos'
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<ImageIcon size={12} /> Fotos
</button>
<button
onClick={() => setMediaType('videos')}
title="Buscar videos"
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
mediaType === 'videos'
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
}`}
>
<Film size={12} /> Videos
</button>
</div>
</div>
{/* Results Grid */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
{items.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-32 text-neutral-600 text-xs">
<Search size={24} className="mb-2 opacity-50" />
<span>Busca fotos o videos gratis</span>
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
{mediaType === 'photos'
? photos.map((photo) => (
<button
key={photo.id}
onClick={() => insertPhoto(photo)}
title={`${photo.alt || 'Foto'}${photo.photographer}`}
className="relative group rounded-lg overflow-hidden aspect-square bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
disabled={isDownloading === photo.id}
>
<img
src={photo.thumbUrl}
alt={photo.alt}
loading="lazy"
className="w-full h-full object-cover"
/>
{/* Overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end">
<span className="text-[8px] text-white/80 px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity truncate">
📷 {photo.photographer}
</span>
</div>
{isDownloading === photo.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 size={20} className="text-violet-400 animate-spin" />
</div>
)}
</button>
))
: videos.map((video) => (
<button
key={video.id}
onClick={() => insertVideo(video)}
title={`Video — ${video.photographer} (${video.duration}s)`}
className="relative group rounded-lg overflow-hidden aspect-video bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
disabled={isDownloading === video.id}
>
<img
src={video.thumbUrl}
alt="Video thumbnail"
loading="lazy"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<Film size={24} className="text-white/70 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<span className="absolute bottom-1 right-1 text-[8px] text-white/80 bg-black/60 rounded px-1 py-0.5">
{video.duration}s
</span>
{isDownloading === video.id && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 size={20} className="text-violet-400 animate-spin" />
</div>
)}
</button>
))}
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-center py-4">
<Loader2 size={16} className="text-violet-400 animate-spin" />
</div>
)}
{/* Infinite scroll sentinel */}
{hasMore && <div ref={sentinelRef} className="h-4" />}
{/* Pexels attribution */}
{items.length > 0 && (
<div className="flex items-center justify-center gap-1 py-3 text-[9px] text-neutral-600">
<ExternalLink size={8} />
Fotos proporcionadas por <a href="https://pexels.com" target="_blank" rel="noopener" className="text-neutral-500 underline">Pexels</a>
</div>
)}
</div>
</div>
);
};
+110
View File
@@ -0,0 +1,110 @@
import React, { useCallback } from 'react';
import { X, Type, Plus, AlignLeft, AlignCenter, Heading1, Heading2, Subtitles } from 'lucide-react';
import { useEditor } from '../../context/EditorContext';
import { TimelineElement } from '../../types';
interface TextPanelProps {
onClose: () => void;
}
const TEXT_PRESETS = [
{ label: 'Título', icon: <Heading1 size={14} />, content: 'Título', fontSize: 72, y: 30 },
{ label: 'Subtítulo', icon: <Heading2 size={14} />, content: 'Subtítulo', fontSize: 48, y: 50 },
{ label: 'Cuerpo', icon: <AlignLeft size={14} />, content: 'Texto de cuerpo', fontSize: 32, y: 60 },
{ label: 'Lower Third', icon: <Subtitles size={14} />, content: 'Lower Third', fontSize: 28, y: 85 },
{ label: 'Centrado', icon: <AlignCenter size={14} />, content: 'Texto Centrado', fontSize: 56, y: 50 },
];
/**
* Panel for adding text elements. Auto-routes to a visual layer.
*/
export const TextPanel: React.FC<TextPanelProps> = ({ onClose }) => {
const {
layers, setLayers,
activeLayerId, setActiveLayerId,
setTimelineElements,
setSelectedElementId,
playerRef,
durationInFrames,
} = useEditor();
const addText = useCallback((content: string, fontSize?: number, y?: number) => {
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
const newId = 'el-' + Date.now();
// Find or create a visual layer
let targetLayerId = activeLayerId;
const activeLayer = layers.find(l => l.id === activeLayerId);
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
if (!visualLayer) {
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
setLayers(prev => [...prev, visualLayer!]);
}
targetLayerId = visualLayer.id;
setActiveLayerId(targetLayerId);
}
const newElement: TimelineElement = {
id: newId,
layerId: targetLayerId,
type: 'text',
content,
startFrame: currentFrame,
endFrame: Math.min(durationInFrames, currentFrame + 100),
x: 50,
y: y ?? 50,
fontSize,
};
setTimelineElements(prev => [...prev, newElement]);
setSelectedElementId(newId);
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId]);
return (
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Type size={14} className="text-violet-400" />
Texto
</h3>
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
<X size={16} />
</button>
</div>
<div className="p-3 flex-1 overflow-y-auto space-y-4">
{/* Quick add */}
<button
onClick={() => addText('Nuevo Texto')}
title="Añadir texto rápido"
className="w-full flex items-center justify-center gap-2 py-2.5 bg-violet-600/20 border border-violet-500/40 text-violet-300 hover:bg-violet-600/30 hover:border-violet-400/60 rounded-lg transition-all text-sm font-medium"
>
<Plus size={14} />
Añadir Texto
</button>
{/* Presets */}
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest">Plantillas</span>
<div className="grid gap-1.5">
{TEXT_PRESETS.map((preset) => (
<button
key={preset.label}
onClick={() => addText(preset.content, preset.fontSize, preset.y)}
title={`Añadir ${preset.label}`}
className="flex items-center gap-2.5 px-3 py-2 bg-neutral-950/50 border border-neutral-800/60 rounded-lg text-neutral-400 hover:text-white hover:border-neutral-700 hover:bg-neutral-800/50 transition-all text-left group"
>
<span className="text-neutral-500 group-hover:text-violet-400 transition-colors">{preset.icon}</span>
<div className="flex flex-col">
<span className="text-[11px] font-medium leading-tight">{preset.label}</span>
<span className="text-[9px] text-neutral-600">{preset.fontSize}px</span>
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,334 @@
import React, { useState, useEffect } from 'react';
import { Music, Trash2, Wand2, Loader2, Volume2, VolumeX, Subtitles } from 'lucide-react';
import { TimelineElement, DesignMD } from '../../types';
import { AudioWaveformCanvas } from '../timeline/AudioWaveformCanvas';
import { formatDuration, getAudioDuration } from '../../utils/audioMetadata';
import { CaptionStylePicker } from '../captions/CaptionStylePicker';
import { generateCaptionElements, CaptionStyle } from '../../utils/captionGenerator';
interface AudioElementPropertiesProps {
element: TimelineElement;
elementIndex: number;
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
setSelectedElementId: (id: string | null) => void;
timeUnit: 'frames' | 'seconds';
activeLayerId: string;
timelineElements: TimelineElement[];
}
/**
* Properties panel for audio elements.
* Shows volume, fade in/out, waveform preview, and subtitle generation.
*/
export const AudioElementProperties: React.FC<AudioElementPropertiesProps> = ({
element: el,
elementIndex: i,
setTimelineElements,
setSelectedElementId,
timeUnit,
activeLayerId,
timelineElements,
}) => {
const [isTranscribing, setIsTranscribing] = useState(false);
const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [showCaptionPicker, setShowCaptionPicker] = useState(false);
const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false);
const update = (updates: Partial<TimelineElement>) => {
setTimelineElements(prev => prev.map((e, idx) => idx === i ? { ...e, ...updates } : e));
};
// Load audio duration
useEffect(() => {
if (el.content) {
getAudioDuration(el.content).then(d => setAudioDuration(d));
}
}, [el.content]);
const clipDuration = el.endFrame - el.startFrame;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
<h2 className="text-xs font-bold text-white flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Audio
</h2>
<button
onClick={() => {
setTimelineElements(prev => prev.filter(e => e.id !== el.id));
setSelectedElementId(null);
}}
title="Eliminar Audio"
className="text-neutral-500 hover:text-red-400 p-1 rounded-md hover:bg-red-500/10 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
<div className="px-4 py-3 overflow-y-auto custom-scrollbar flex-1 space-y-5">
{/* ═══ Waveform Preview ═══ */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Forma de Onda</label>
<div className="bg-neutral-950 border border-neutral-800 rounded-lg p-2 relative overflow-hidden">
<AudioWaveformCanvas
src={el.content}
width={240}
height={48}
color="rgba(129, 140, 248, 0.6)"
/>
{audioDuration !== null && (
<div className="absolute bottom-1 right-2 text-[9px] text-neutral-500 font-mono">
{formatDuration(audioDuration)}
</div>
)}
</div>
{el.originalFileName && (
<p className="text-[9px] text-neutral-600 mt-1 truncate">{el.originalFileName}</p>
)}
</div>
{/* ═══ Volume ═══ */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Volumen</label>
<span className="text-[10px] text-neutral-400 font-mono">{Math.round((el.volume ?? 1) * 100)}%</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => update({ volume: el.volume === 0 ? 1 : 0 })}
className={`p-1.5 rounded-md transition-colors ${el.volume === 0 ? 'bg-red-500/20 text-red-400' : 'bg-neutral-800 text-neutral-400 hover:text-white'}`}
title={el.volume === 0 ? "Activar Sonido" : "Silenciar"}
>
{el.volume === 0 ? <VolumeX size={14} /> : <Volume2 size={14} />}
</button>
<input
type="range"
min="0" max="200" step="1"
value={Math.round((el.volume ?? 1) * 100)}
onChange={(e) => update({ volume: Number(e.target.value) / 100 })}
className="flex-1 accent-violet-500 h-1"
title="Volumen del clip"
/>
</div>
</div>
{/* ═══ Fade In / Out ═══ */}
<div className="space-y-3">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Fundidos</label>
{/* Fade In */}
<div>
<div className="flex justify-between text-[10px] text-neutral-500 mb-0.5">
<span>Fade In</span>
<span className="font-mono">
{el.fadeInFrames ?? 0}f ({((el.fadeInFrames ?? 0) / 30).toFixed(1)}s)
</span>
</div>
<input
type="range"
min="0" max={Math.floor(clipDuration / 2)} step="1"
value={el.fadeInFrames ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value);
update({ fadeInFrames: v > 0 ? v : undefined });
}}
className="w-full accent-amber-500 h-1"
title="Duración del fundido de entrada"
/>
</div>
{/* Fade Out */}
<div>
<div className="flex justify-between text-[10px] text-neutral-500 mb-0.5">
<span>Fade Out</span>
<span className="font-mono">
{el.fadeOutFrames ?? 0}f ({((el.fadeOutFrames ?? 0) / 30).toFixed(1)}s)
</span>
</div>
<input
type="range"
min="0" max={Math.floor(clipDuration / 2)} step="1"
value={el.fadeOutFrames ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value);
update({ fadeOutFrames: v > 0 ? v : undefined });
}}
className="w-full accent-amber-500 h-1"
title="Duración del fundido de salida"
/>
</div>
{/* Quick Fade Presets */}
<div className="flex gap-1.5">
{[
{ label: 'Sin Fade', fadeIn: 0, fadeOut: 0 },
{ label: 'Suave', fadeIn: 15, fadeOut: 15 },
{ label: 'Largo', fadeIn: 45, fadeOut: 45 },
].map(preset => {
const isActive = (el.fadeInFrames ?? 0) === preset.fadeIn && (el.fadeOutFrames ?? 0) === preset.fadeOut;
return (
<button
key={preset.label}
onClick={() => update({
fadeInFrames: preset.fadeIn > 0 ? preset.fadeIn : undefined,
fadeOutFrames: preset.fadeOut > 0 ? preset.fadeOut : undefined,
})}
className={`flex-1 py-1.5 rounded-lg text-[9px] font-medium transition-all border ${
isActive
? 'bg-amber-600/20 border-amber-500/50 text-amber-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300'
}`}
title={preset.label}
>
{preset.label}
</button>
);
})}
</div>
</div>
{/* ═══ Timing ═══ */}
<div>
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Tiempos</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-neutral-500 mb-0.5">Inicio ({timeUnit === 'frames' ? 'f' : 's'})</label>
<input
type="number"
step={timeUnit === 'seconds' ? 0.01 : 1}
value={timeUnit === 'frames' ? el.startFrame : Number((el.startFrame / 30).toFixed(2))}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
update({ startFrame: timeUnit === 'seconds' ? Math.round(val * 30) : Math.round(val) });
}}
className="bg-neutral-950 rounded-lg px-2 py-1.5 w-full border border-neutral-800 outline-none text-center font-mono text-xs focus:border-violet-500/50"
/>
</div>
<div>
<label className="block text-[10px] text-neutral-500 mb-0.5">Fin ({timeUnit === 'frames' ? 'f' : 's'})</label>
<input
type="number"
step={timeUnit === 'seconds' ? 0.01 : 1}
value={timeUnit === 'frames' ? el.endFrame : Number((el.endFrame / 30).toFixed(2))}
onChange={(e) => {
const val = parseFloat(e.target.value) || 1;
update({ endFrame: timeUnit === 'seconds' ? Math.round(val * 30) : Math.round(val) });
}}
className="bg-neutral-950 rounded-lg px-2 py-1.5 w-full border border-neutral-800 outline-none text-center font-mono text-xs focus:border-violet-500/50"
/>
</div>
</div>
</div>
{/* ═══ Subtítulos ═══ */}
<div className="space-y-2">
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider">Subtítulos</label>
<button
disabled={isTranscribing}
onClick={async () => {
try {
setIsTranscribing(true);
const res = await fetch(el.content);
const blob = await res.blob();
const file = new File([blob], "audio.mp3", { type: el.content.startsWith("data:") ? "audio/mpeg" : blob.type });
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
if (data.text) {
const newTextEl: TimelineElement = {
id: Date.now().toString(),
layerId: activeLayerId,
type: 'text',
content: data.text,
startFrame: el.startFrame,
endFrame: el.endFrame,
x: 20, y: 80,
shadowOffset: 3, shadowBlur: 6
};
setTimelineElements(prev => [...prev, newTextEl]);
}
} catch (err) {
console.error("Error generating subtitles:", err);
alert("Error al generar subtítulos.");
} finally {
setIsTranscribing(false);
}
}}
title="Generar Subtítulos Automáticos"
className={`w-full font-medium py-2 px-3 rounded-lg transition-colors flex items-center justify-center gap-2 text-xs ${isTranscribing ? 'bg-neutral-800 text-neutral-400 cursor-not-allowed' : 'bg-violet-600 hover:bg-violet-500 text-white'}`}
>
{isTranscribing ? (
<><Loader2 size={12} className="animate-spin" /> Transcribiendo...</>
) : (
<><Wand2 size={12} /> Generar Subtítulos</>
)}
</button>
<p className="text-[9px] text-neutral-600 text-center">Whisper Large V3 (Groq)</p>
{/* Auto-Captions Button */}
<button
onClick={() => setShowCaptionPicker(true)}
title="Generar subtítulos sincronizados palabra por palabra"
className="w-full font-medium py-2 px-3 rounded-lg transition-colors flex items-center justify-center gap-2 text-xs bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 text-white"
>
<Subtitles size={12} /> Auto-Captions (Palabra x Palabra)
</button>
</div>
</div>
{/* Caption Style Picker Modal */}
<CaptionStylePicker
isOpen={showCaptionPicker}
onClose={() => setShowCaptionPicker(false)}
isLoading={isGeneratingCaptions}
onGenerate={async (style: CaptionStyle) => {
try {
setIsGeneratingCaptions(true);
// 1. Fetch audio file
const res = await fetch(el.content);
const blob = await res.blob();
const file = new File([blob], "audio.mp3", { type: blob.type || "audio/mpeg" });
const formData = new FormData();
formData.append('file', file);
// 2. Transcribe with word-level timestamps
const response = await fetch('/api/transcribe', { method: 'POST', body: formData });
if (!response.ok) throw new Error(await response.text());
const data = await response.json();
if (!data.words || data.words.length === 0) {
alert('No se detectaron palabras en el audio.');
return;
}
// 3. Create captions layer
const captionLayerId = 'layer-captions-' + Date.now();
// 4. Generate caption elements
const captionElements = generateCaptionElements(
data.words,
30, // fps
el.startFrame,
captionLayerId,
style,
);
// 5. Add layer and elements
setTimelineElements(prev => [...prev, ...captionElements]);
setShowCaptionPicker(false);
} catch (err) {
console.error('Auto-caption error:', err);
alert('Error al generar auto-captions.');
} finally {
setIsGeneratingCaptions(false);
}
}}
/>
</div>
);
};
@@ -0,0 +1,73 @@
import React, { RefObject } from 'react';
import { Music } from 'lucide-react';
import { TimelineElement } from '../../types';
import { PlayerRef } from '@remotion/player';
import { uploadMedia } from '../../utils/mediaUploader';
import { FileDropZone } from '../ui/FileDropZone';
import { getAudioDuration, durationToFrames } from '../../utils/audioMetadata';
interface AudioLayerPanelProps {
activeLayerId: string;
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
timelineElements: TimelineElement[];
playerRef: RefObject<PlayerRef | null>;
endFrameLimit?: number;
}
export const AudioLayerPanel: React.FC<AudioLayerPanelProps> = ({
activeLayerId,
setTimelineElements,
timelineElements,
playerRef,
endFrameLimit = 150
}) => {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1">
<Music size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Capa de Audio
</h2>
<p className="text-[11px] text-neutral-400">Añade o edita pistas de audio</p>
</div>
<div className="p-5 flex-1 space-y-6 overflow-y-auto custom-scrollbar">
<div>
<label className="block text-xs font-medium text-neutral-300 mb-2">Añadir Audio (MP3/WAV)</label>
<FileDropZone
accept="audio/*"
label="Subir Audio"
sublabel="MP3, WAV o M4A — o arrastra aquí"
onFiles={async (files) => {
const file = files[0];
if (!file || !playerRef.current) return;
try {
const result = await uploadMedia(file);
const currentFrame = playerRef.current.getCurrentFrame() || 0;
// Get real audio duration
let endFrame = Math.min(endFrameLimit, currentFrame + 150);
try {
const dur = await getAudioDuration(result.url);
endFrame = currentFrame + durationToFrames(dur);
} catch {}
setTimelineElements(prev => [...prev, {
id: Date.now().toString(),
layerId: activeLayerId,
type: 'audio',
content: result.url,
startFrame: currentFrame,
endFrame,
x: 0,
y: 0,
originalFileName: result.originalName,
}]);
} catch (err) {
console.error('Audio upload failed:', err);
}
}}
/>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,362 @@
import React, { useCallback, RefObject } from 'react';
import { Film, Play, Camera, Download, Grid3x3, Palette, Maximize } from 'lucide-react';
import { CollapsibleSection } from '../ui/CollapsibleSection';
import { PlayerRef } from '@remotion/player';
import { EXPORT_PRESETS } from '../../config/constants';
import { TimelineElement, TimelineLayer } from '../../types';
import { ProjectStats } from '../ui/ProjectStats';
import { QuickElementTemplates } from '../ui/QuickElementTemplates';
import { BulkActionsBar } from '../ui/BulkActionsBar';
interface GlobalSettingsPanelProps {
textOverlay: string;
setTextOverlay: (text: string) => void;
playerRef?: RefObject<PlayerRef | null>;
aspectRatio?: '16:9' | '9:16' | '1:1' | '4:5' | '4:3';
outputFormat?: 'video' | 'image';
onExportClick?: () => void;
timelineElements?: TimelineElement[];
setTimelineElements?: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
showGrid?: boolean;
setShowGrid?: (show: boolean) => void;
showSafeZone?: boolean;
setShowSafeZone?: (show: boolean) => void;
onShowRenderHistory?: () => void;
layers?: TimelineLayer[];
durationInFrames?: number;
fps?: number;
}
export const GlobalSettingsPanel: React.FC<GlobalSettingsPanelProps> = ({
textOverlay, setTextOverlay, playerRef, aspectRatio, outputFormat, onExportClick,
timelineElements, setTimelineElements, showGrid, setShowGrid, showSafeZone, setShowSafeZone,
onShowRenderHistory, layers, durationInFrames, fps,
}) => {
// ═══ Export frame as PNG ═══
const handleExportFrame = useCallback(() => {
const player = playerRef?.current;
if (!player) return;
// Find the remotion-player container and grab its inner canvas/iframe
const playerContainer = document.querySelector('[data-remotion-player]') ?? document.querySelector('.remotion-player');
if (!playerContainer) {
// Fallback: find the iframe or video element
const iframe = document.querySelector('iframe');
if (iframe) {
// Can't capture cross-origin iframe, but for same-origin:
try {
const iframeDoc = iframe.contentDocument;
if (iframeDoc) {
const canvas = document.createElement('canvas');
const body = iframeDoc.body;
canvas.width = body.scrollWidth;
canvas.height = body.scrollHeight;
// This is a simplistic approach - Remotion doesn't expose easy screenshot
}
} catch { /* cross-origin */ }
}
}
// Use html2canvas-like approach: create a temporary canvas from the player
// For now, use a simple screenshot via the Remotion player's renderToCanvas
alert('📸 Exportar frame: Esta función requiere @remotion/renderer para renderizar frames individuales. Próximamente disponible.');
}, [playerRef]);
const matchingPresets = EXPORT_PRESETS.filter(p => p.aspect === aspectRatio);
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1"><Film size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Configuración Global</h2>
<p className="text-[11px] text-neutral-400">Parámetros base del render.</p>
</div>
<div className="p-5 flex-1 space-y-6 overflow-y-auto custom-scrollbar">
{/* Text Overlay */}
{/* Project Stats */}
{timelineElements && layers && durationInFrames && (
<div className="bg-neutral-950/30 border border-neutral-800/30 rounded-lg p-2.5">
<ProjectStats
timelineElements={timelineElements}
layers={layers}
durationInFrames={durationInFrames}
fps={fps ?? 30}
/>
</div>
)}
{/* ── Herramientas Avanzadas (collapsible) ── */}
<CollapsibleSection title="Herramientas">
{/* Quick Templates */}
{setTimelineElements && (
<div className="bg-neutral-950/30 border border-neutral-800/30 rounded-lg p-2.5">
<QuickElementTemplates
onAddElement={(partial) => {
const newEl: TimelineElement = {
id: 'el-' + Date.now(),
type: partial.type || 'text',
content: partial.content || '',
startFrame: 0,
endFrame: 150,
layerId: 'default',
...partial,
} as TimelineElement;
setTimelineElements(prev => [...prev, newEl]);
}}
/>
</div>
)}
{/* Bulk Actions */}
{timelineElements && setTimelineElements && (
<div className="bg-neutral-950/30 border border-neutral-800/30 rounded-lg p-2.5">
<BulkActionsBar
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
setSelectedElementId={() => {}}
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-neutral-300 mb-2">Pie de Mensaje Fijo</label>
<textarea
value={textOverlay}
onChange={(e) => setTextOverlay(e.target.value)}
rows={2}
placeholder="Mensaje inferior..."
className="bg-neutral-950 text-sm rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 resize-none"
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="Opciones de Fondo">
{/* Background Pattern Presets */}
{timelineElements && setTimelineElements && (
<div>
<span className="text-[9px] text-neutral-500 block mb-1">Patrones</span>
<div className="grid grid-cols-5 gap-1">
{[
{ name: 'Ninguno', bg: 'none', preview: '⊘' },
{ name: 'Puntos', bg: 'radial-gradient(circle, rgba(255,255,255,0.15) 1px, transparent 1px)', preview: '⋯' },
{ name: 'Líneas', bg: 'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(255,255,255,0.08) 10px, rgba(255,255,255,0.08) 12px)', preview: '' },
{ name: 'Cuadrícula', bg: 'repeating-linear-gradient(0deg, rgba(255,255,255,0.06) 0px, rgba(255,255,255,0.06) 1px, transparent 1px, transparent 20px), repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0px, rgba(255,255,255,0.06) 1px, transparent 1px, transparent 20px)', preview: '⊞' },
{ name: 'Damero', bg: 'repeating-conic-gradient(rgba(255,255,255,0.05) 0% 25%, transparent 0% 50%) 0 0 / 20px 20px', preview: '⊟' },
].map(pattern => (
<button
key={pattern.name}
onClick={() => {
if (!setTimelineElements) return;
setTimelineElements(prev => prev.map(el => {
if (el.type !== 'color') return el;
return { ...el, backgroundPattern: pattern.bg === 'none' ? undefined : pattern.bg };
}));
}}
title={pattern.name}
className="py-1 rounded text-[9px] font-medium bg-neutral-900 border border-neutral-800 text-neutral-500 hover:text-violet-300 hover:border-violet-500/30 transition-all"
>
{pattern.preview}
</button>
))}
</div>
</div>
)}
{/* Background Image Upload */}
{setTimelineElements && (
<div>
<span className="text-[9px] text-neutral-500 block mb-1">Imagen de fondo</span>
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-neutral-700 hover:border-violet-500 cursor-pointer transition-colors text-[10px] text-neutral-400 hover:text-violet-300">
<Camera size={12} />
Subir imagen de fondo
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file || !setTimelineElements) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.url) {
const bgEl: TimelineElement = {
id: crypto.randomUUID(),
layerId: 'background',
type: 'sticker',
content: data.url,
startFrame: 0,
endFrame: 9999,
x: 50, y: 50,
width: 100,
objectFit: 'cover',
};
setTimelineElements(prev => [bgEl, ...prev]);
}
} catch (err) {
console.error('Upload failed:', err);
}
e.target.value = '';
}}
/>
</label>
</div>
)}
</CollapsibleSection>
<CollapsibleSection title="Vista">
{/* Grid Toggle */}
{setShowGrid && (
<div className="flex items-center justify-between py-1">
<label className="text-xs font-medium text-neutral-300 flex items-center gap-1.5">
<Grid3x3 size={12} className="text-neutral-500" />
Cuadrícula
</label>
<button
onClick={() => setShowGrid(!showGrid)}
title={showGrid ? "Ocultar cuadrícula" : "Mostrar cuadrícula"}
className={`px-3 py-1 rounded-md text-[10px] font-medium transition-all border ${
showGrid
? 'bg-violet-500/20 border-violet-500/50 text-violet-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-neutral-300'
}`}
>
{showGrid ? 'ON' : 'OFF'}
</button>
</div>
)}
{/* Safe Zone Toggle */}
{setShowSafeZone && (
<div className="flex items-center justify-between py-1">
<label className="text-xs font-medium text-neutral-300 flex items-center gap-1.5">
<Maximize size={12} className="text-neutral-500" />
Zona Segura
</label>
<button
onClick={() => setShowSafeZone(!showSafeZone)}
title={showSafeZone ? "Ocultar zona segura" : "Mostrar zona segura"}
className={`px-3 py-1 rounded-md text-[10px] font-medium transition-all border ${
showSafeZone
? 'bg-pink-500/20 border-pink-500/50 text-pink-300'
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:text-neutral-300'
}`}
>
{showSafeZone ? 'ON' : 'OFF'}
</button>
</div>
)}
</CollapsibleSection>
</div>
{/* Project Stats */}
{timelineElements && (
<div className="px-5 py-3 border-t border-neutral-800/40">
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider block mb-2">Estadísticas</span>
<div className="grid grid-cols-5 gap-1">
{[
{ type: 'text', icon: '📝', color: '#a78bfa' },
{ type: 'image', icon: '🖼️', color: '#34d399' },
{ type: 'video', icon: '🎬', color: '#f472b6' },
{ type: 'audio', icon: '🎵', color: '#38bdf8' },
{ type: 'sticker', icon: '⭐', color: '#fbbf24' },
].map(item => {
const count = timelineElements.filter(e => e.type === item.type && !e.isBrandElement).length;
return (
<div key={item.type} className="flex flex-col items-center gap-0.5 py-1 rounded bg-neutral-900/50">
<span className="text-xs">{item.icon}</span>
<span className="text-[10px] font-bold" style={{ color: item.color }}>{count}</span>
</div>
);
})}
</div>
<div className="flex justify-between text-[9px] text-neutral-600 font-mono mt-1">
<span>Total: {timelineElements.filter(e => !e.isBrandElement).length} elementos</span>
<span>{aspectRatio ?? '9:16'}</span>
</div>
</div>
)}
<div className="p-5 border-t border-neutral-800 shrink-0 space-y-2">
{/* Capture frame button */}
<button
title="Exportar Frame como PNG"
onClick={onExportClick}
className="w-full bg-neutral-800 hover:bg-neutral-700 text-neutral-300 hover:text-white font-medium py-2 rounded-lg transition-colors flex items-center justify-center gap-2 text-xs border border-neutral-700"
>
<Camera size={14} /> Capturar Frame
</button>
{/* Render button */}
<button
title="Exportar Video"
onClick={onExportClick}
className="w-full bg-violet-600 hover:bg-violet-500 text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 text-sm shadow-xl shadow-violet-900/20"
>
<Play size={16} fill="currentColor" /> Renderizar
</button>
{/* Project Save/Load */}
<div className="flex gap-1.5 mt-1">
<button
title="Guardar proyecto como JSON"
onClick={() => {
const data = JSON.stringify({
version: 1,
aspectRatio,
timelineElements: timelineElements.filter(e => !e.isBrandElement),
exportedAt: new Date().toISOString(),
}, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `project-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-[10px] border border-neutral-700"
>
💾 Guardar
</button>
<button
title="Cargar proyecto desde JSON"
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (data.timelineElements && Array.isArray(data.timelineElements)) {
setTimelineElements(prev => {
const brandElements = prev.filter(el => el.isBrandElement);
return [...brandElements, ...data.timelineElements];
});
}
} catch { /* silently fail */ }
};
input.click();
}}
className="flex-1 bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-[10px] border border-neutral-700"
>
📂 Cargar
</button>
</div>
{/* Render History */}
{onShowRenderHistory && (
<button
onClick={onShowRenderHistory}
title="Ver historial de renders"
className="w-full bg-neutral-800 hover:bg-neutral-700 text-neutral-400 hover:text-white py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 text-[10px] border border-neutral-700 mt-1"
>
📋 Historial de Renders
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,21 @@
import React from 'react';
import { Layers, Image as ImageIcon } from 'lucide-react';
export const GraphicLayerPanel: React.FC = () => {
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-5 border-b border-neutral-800">
<h2 className="text-sm font-bold text-white mb-1">
<Layers size={16} className="inline mr-2 text-violet-400 align-text-bottom"/> Capa Gráfica
</h2>
<p className="text-[11px] text-neutral-400">Añade textos o imágenes a esta capa</p>
</div>
<div className="p-5 flex-1 space-y-6 overflow-y-auto custom-scrollbar">
<div className="text-center text-neutral-500 text-sm py-10 px-4">
<ImageIcon size={24} className="mx-auto mb-3 opacity-50" />
Selecciona un elemento en la línea temporal para editarlo, o usa la barra de herramientas para añadir uno nuevo.
</div>
</div>
</div>
);
};
@@ -0,0 +1,345 @@
import React, { useState, useCallback } from 'react';
import { Eye, EyeOff, Lock, Unlock, Trash2, Type, Image as ImageIcon, Palette, Film, GripVertical, Copy, Layers } from 'lucide-react';
import { TimelineElement, TimelineLayer } from '../../types';
interface ImageLayersPanelProps {
timelineElements: TimelineElement[];
setTimelineElements: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
layers: TimelineLayer[];
selectedElementId: string | null;
setSelectedElementId: (id: string | null) => void;
}
const ELEMENT_ICONS: Record<string, React.ReactNode> = {
text: <Type size={16} />,
image: <ImageIcon size={16} />,
sticker: <ImageIcon size={16} />,
video: <Film size={16} />,
color: <Palette size={16} />,
};
const TYPE_LABELS: Record<string, string> = {
text: 'TEXTO',
image: 'IMAGEN',
sticker: 'STICKER',
video: 'VIDEO',
color: 'COLOR',
};
/**
* Photoshop-style layers panel for image editing mode.
* Features: drag reorder, visibility, opacity, lock, duplicate, delete.
* Elements are shown in reverse order (top-most layer first).
*/
export const ImageLayersPanel: React.FC<ImageLayersPanelProps> = ({
timelineElements,
setTimelineElements,
layers,
selectedElementId,
setSelectedElementId,
}) => {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [dragFromIndex, setDragFromIndex] = useState<number | null>(null);
const [editingOpacityId, setEditingOpacityId] = useState<string | null>(null);
// Reversed for display: top-most layer (last in array) shown first
const sortedElements = [...timelineElements].reverse();
// ─── Actions ──────────────────────────────
const toggleVisibility = (id: string) => {
setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, opacity: (el.opacity === 0 ? 1 : 0) } : el
));
};
const toggleLock = (id: string) => {
setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, isLocked: !el.isLocked } : el
));
};
const setOpacity = (id: string, value: number) => {
setTimelineElements(prev => prev.map(el =>
el.id === id ? { ...el, opacity: value } : el
));
};
const duplicateElement = (id: string) => {
setTimelineElements(prev => {
const el = prev.find(e => e.id === id);
if (!el) return prev;
const copy: TimelineElement = {
...el,
id: 'el-' + Date.now(),
x: el.x + 3,
y: el.y + 3,
isBrandElement: false,
};
const idx = prev.findIndex(e => e.id === id);
const next = [...prev];
next.splice(idx + 1, 0, copy);
return next;
});
};
const deleteElement = (id: string) => {
const el = timelineElements.find(e => e.id === id);
if (el?.isBrandElement) return;
setTimelineElements(prev => prev.filter(el => el.id !== id));
if (selectedElementId === id) setSelectedElementId(null);
};
// ─── Drag & Drop Reorder ──────────────────────────────
const handleDragStart = useCallback((e: React.DragEvent, visualIndex: number) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(visualIndex));
setDragFromIndex(visualIndex);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, visualIndex: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(visualIndex);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetVisualIndex: number) => {
e.preventDefault();
const fromVisual = parseInt(e.dataTransfer.getData('text/plain'));
if (isNaN(fromVisual) || fromVisual === targetVisualIndex) {
setDragOverIndex(null);
setDragFromIndex(null);
return;
}
// Convert visual indices (reversed) to actual array indices
const fromActual = timelineElements.length - 1 - fromVisual;
const toActual = timelineElements.length - 1 - targetVisualIndex;
setTimelineElements(prev => {
const next = [...prev];
const [moved] = next.splice(fromActual, 1);
next.splice(toActual, 0, moved);
return next;
});
setDragOverIndex(null);
setDragFromIndex(null);
}, [timelineElements.length, setTimelineElements]);
const handleDragEnd = useCallback(() => {
setDragOverIndex(null);
setDragFromIndex(null);
}, []);
// ─── Helpers ──────────────────────────────
const getLabel = (el: TimelineElement): string => {
if (el.type === 'text') {
const preview = el.content.slice(0, 24);
return preview.length < el.content.length ? `${preview}` : preview;
}
return TYPE_LABELS[el.type] || el.type;
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-2.5 border-b border-neutral-800 shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers size={12} className="text-neutral-500" />
<h3 className="text-[10px] font-bold tracking-widest text-neutral-400 uppercase">Capas</h3>
</div>
<span className="text-[10px] text-neutral-600 font-mono">{sortedElements.length}</span>
</div>
{/* Layers list */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{sortedElements.length === 0 ? (
<div className="p-8 text-center text-neutral-600">
<Layers size={24} className="mx-auto mb-2 opacity-30" />
<p className="text-[11px] font-medium">Sin elementos</p>
<p className="text-[10px] mt-1 text-neutral-700">Usa las herramientas para agregar capas</p>
</div>
) : (
sortedElements.map((el, visualIndex) => {
const isSelected = selectedElementId === el.id;
const isHidden = el.opacity === 0;
const isLocked = el.isLocked || el.isBrandElement;
const isDragOver = dragOverIndex === visualIndex;
const isDragging = dragFromIndex === visualIndex;
const showOpacity = editingOpacityId === el.id;
const opacityValue = el.opacity ?? 1;
return (
<div key={el.id}>
{/* Drop indicator */}
{isDragOver && !isDragging && (
<div className="h-0.5 bg-violet-500 mx-2 rounded-full shadow-[0_0_6px_rgba(139,92,246,0.6)]" />
)}
<div
draggable={!isLocked}
onDragStart={(e) => handleDragStart(e, visualIndex)}
onDragOver={(e) => handleDragOver(e, visualIndex)}
onDrop={(e) => handleDrop(e, visualIndex)}
onDragEnd={handleDragEnd}
onClick={() => setSelectedElementId(el.id)}
className={`group flex items-stretch border-b border-neutral-800/30 transition-all cursor-pointer ${
isSelected
? 'bg-violet-950/50 border-l-2 border-l-violet-500'
: 'bg-transparent hover:bg-neutral-800/30 border-l-2 border-l-transparent'
} ${isDragging ? 'opacity-30' : ''} ${isHidden ? 'opacity-50' : ''}`}
>
{/* Drag grip */}
<div className={`w-5 flex items-center justify-center shrink-0 ${isLocked ? 'cursor-not-allowed' : 'cursor-grab active:cursor-grabbing'}`}>
<GripVertical size={10} className="text-neutral-700 group-hover:text-neutral-500 transition-colors" />
</div>
{/* Visibility toggle */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleVisibility(el.id); }}
title={isHidden ? 'Mostrar' : 'Ocultar'}
className="w-6 flex items-center justify-center shrink-0 text-neutral-600 hover:text-white transition-colors"
>
{isHidden ? <EyeOff size={11} /> : <Eye size={11} className={isSelected ? 'text-violet-400' : ''} />}
</button>
{/* Thumbnail */}
<div className={`w-10 h-10 my-1 rounded flex items-center justify-center shrink-0 overflow-hidden ${
isSelected ? 'ring-1 ring-violet-500/50' : ''
}`}>
{(el.type === 'image' || el.type === 'sticker') ? (
<img
src={el.content}
alt=""
className="w-full h-full object-cover rounded"
draggable={false}
/>
) : el.type === 'video' ? (
<div className="w-full h-full bg-sky-950/50 rounded flex items-center justify-center">
<Film size={14} className="text-sky-400" />
</div>
) : el.type === 'color' ? (
<div className="w-full h-full rounded" style={{ backgroundColor: el.content || '#000' }} />
) : (
<div className={`w-full h-full rounded flex items-center justify-center ${
isSelected ? 'bg-violet-950/50' : 'bg-neutral-900'
}`}>
<span className={isSelected ? 'text-violet-400' : 'text-neutral-600'}>
{ELEMENT_ICONS[el.type] || <ImageIcon size={14} />}
</span>
</div>
)}
</div>
{/* Label + type + opacity */}
<div className="flex-1 min-w-0 py-1.5 pl-2 flex flex-col justify-center">
<p className={`text-[11px] font-medium truncate leading-tight ${isSelected ? 'text-white' : 'text-neutral-300'}`}>
{getLabel(el)}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[8px] text-neutral-600 uppercase tracking-wider font-semibold">
{TYPE_LABELS[el.type] || el.type}
</span>
{opacityValue < 1 && opacityValue > 0 && (
<span className="text-[8px] text-neutral-600 font-mono">
{Math.round(opacityValue * 100)}%
</span>
)}
</div>
</div>
{/* Actions */}
<div className={`flex items-center gap-0 px-1 shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
{/* Lock */}
{el.isBrandElement ? (
<span className="w-5 h-5 flex items-center justify-center text-amber-500" title="Marca (protegido)">
<Lock size={10} />
</span>
) : (
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleLock(el.id); }}
title={el.isLocked ? 'Desbloquear' : 'Bloquear'}
className={`w-5 h-5 flex items-center justify-center rounded transition-colors ${
el.isLocked ? 'text-amber-400' : 'text-neutral-600 hover:text-neutral-300'
}`}
>
{el.isLocked ? <Lock size={10} /> : <Unlock size={10} />}
</button>
)}
{/* Opacity toggle */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); setEditingOpacityId(showOpacity ? null : el.id); }}
title="Opacidad"
className={`w-5 h-5 flex items-center justify-center rounded text-[9px] font-mono transition-colors ${
showOpacity ? 'text-violet-400' : 'text-neutral-600 hover:text-neutral-300'
}`}
>
α
</button>
{/* Duplicate */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); duplicateElement(el.id); }}
title="Duplicar capa"
className="w-5 h-5 flex items-center justify-center rounded text-neutral-600 hover:text-white transition-colors"
>
<Copy size={10} />
</button>
{/* Delete */}
{!el.isBrandElement && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); deleteElement(el.id); }}
title="Eliminar capa"
className="w-5 h-5 flex items-center justify-center rounded text-neutral-600 hover:text-rose-400 transition-colors"
>
<Trash2 size={10} />
</button>
)}
</div>
</div>
{/* Inline opacity slider */}
{showOpacity && (
<div className="px-4 py-2 bg-neutral-950/60 border-b border-neutral-800/30 flex items-center gap-3">
<span className="text-[9px] text-neutral-500 w-12 shrink-0">Opacidad</span>
<input
type="range"
min="0" max="1" step="0.01"
value={opacityValue}
onChange={(e) => setOpacity(el.id, parseFloat(e.target.value))}
onClick={(e) => e.stopPropagation()}
className="flex-1 h-1 accent-violet-500"
/>
<span className="text-[9px] text-neutral-500 font-mono w-8 text-right">
{Math.round(opacityValue * 100)}%
</span>
</div>
)}
</div>
);
})
)}
</div>
{/* Footer info */}
<div className="px-3 py-2 border-t border-neutral-800 shrink-0 flex items-center justify-between">
<span className="text-[9px] text-neutral-600">
Arrastra para reordenar
</span>
<span className="text-[9px] text-neutral-700 font-mono">
z-index
</span>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More