diff --git a/server.ts b/server.ts index 0581884..5c7df17 100644 --- a/server.ts +++ b/server.ts @@ -54,6 +54,59 @@ export async function createExpressApp() { // Add JSON parser with generous limit for render payloads (timelineElements can be large) app.use(express.json({ limit: '50mb' })); + // ═══ Dynamic Workspace Serving ═══ + let activeWorkspacePath = ""; + + app.post("/api/config/workspace", express.json(), (req, res) => { + activeWorkspacePath = req.body.path; + res.json({ success: true, path: activeWorkspacePath }); + }); + + app.use("/workspace", (req, res, next) => { + if (activeWorkspacePath) { + const reqPath = decodeURIComponent(req.path); + const fullPath = path.join(activeWorkspacePath, reqPath); + // Validate path traversal + if (!fullPath.startsWith(activeWorkspacePath)) { + return res.status(403).send("Forbidden"); + } + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { + return res.sendFile(fullPath); + } + } + next(); + }); + + // Upload an asset directly to a brand's workspace folder + app.post("/api/upload/brand", mediaUpload.single("file"), (req, res) => { + try { + if (!req.file) return res.status(400).json({ error: "No file uploaded" }); + const { brandId, workspacePath } = req.body; + if (!brandId || !workspacePath) { + return res.status(400).json({ error: "brandId and workspacePath required" }); + } + + const brandDir = path.join(workspacePath, brandId, "brand"); + if (!fs.existsSync(brandDir)) { + fs.mkdirSync(brandDir, { recursive: true }); + } + + // Move file from UPLOADS_DIR to brandDir + const ext = path.extname(req.file.originalname) || ''; + const finalFilename = `${crypto.randomUUID()}${ext}`; + const finalPath = path.join(brandDir, finalFilename); + + fs.renameSync(req.file.path, finalPath); + + // Return the public URL that routes through our dynamic /workspace middleware + const publicUrl = `http://localhost:3000/workspace/${brandId}/brand/${finalFilename}`; + res.json({ url: publicUrl, path: finalPath }); + } catch (error) { + console.error("Brand upload error:", error); + res.status(500).json({ error: "Upload failed" }); + } + }); + // ═══ Serve uploaded media files ═══ app.use("/api/media", express.static(UPLOADS_DIR, { maxAge: "1d", @@ -212,7 +265,7 @@ export async function createExpressApp() { // Start a render job app.post("/api/render/start", async (req, res) => { try { - const { format, width, height, fps, durationInFrames, compositionId, inputProps } = req.body; + const { format, width, height, fps, durationInFrames, compositionId, inputProps, targetPath, brandId } = req.body; if (!format || !width || !height || !compositionId) { return res.status(400).json({ error: "Missing required fields: format, width, height, compositionId" }); @@ -227,6 +280,8 @@ export async function createExpressApp() { durationInFrames: durationInFrames || 150, compositionId, inputProps: inputProps || {}, + targetPath, + brandId }); console.log(`🎬 Job created: ${job.id} (${format} ${width}x${height})`); diff --git a/src/App.tsx b/src/App.tsx index 2dc02fa..b785afa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { EditorProvider } from './context/EditorContext'; import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults'; import { useCustomTooltips } from './hooks/useCustomTooltips'; import { useToast } 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'; @@ -36,9 +36,7 @@ function saveContentData(data: Record(() => { - return loadCompanies() ?? PREDEFINED_COMPANIES; - }); + const [companies, setCompanies] = useState(PREDEFINED_COMPANIES); const [currentCompanyId, setCurrentCompanyId] = useState(null); const [currentProjectId, setCurrentProjectId] = useState(null); const [currentStep, setCurrentStep] = useState('dashboard'); @@ -47,9 +45,7 @@ export default function App() { const [editingBrandAsset, setEditingBrandAsset] = useState<{ type: keyof DesignMD; url: string } | null>(null); // Global templates (decoupled from brands) — persisted - const [globalTemplates, setGlobalTemplates] = useState(() => { - return loadTemplates() ?? []; - }); + const [globalTemplates, setGlobalTemplates] = useState([]); const [templateBuilderFormat, setTemplateBuilderFormat] = useState<'video' | 'image'>('image'); const [templateBuilderAspect, setTemplateBuilderAspect] = useState('9:16'); const [editingGlobalTemplate, setEditingGlobalTemplate] = useState(null); @@ -64,7 +60,8 @@ export default function App() { ...globalTemplates, ], [globalTemplates]); - const handleSaveGlobalTemplate = useCallback((template: ExpressTemplate) => { + const handleSaveGlobalTemplate = useCallback(async (template: ExpressTemplate) => { + // Optimistic update setGlobalTemplates(prev => { const existing = prev.findIndex(t => t.id === template.id); if (existing >= 0) { @@ -74,33 +71,22 @@ export default function App() { } return [...prev, template]; }); + // Persist to FS + if (window.electronAPI) { + await window.electronAPI.fs.saveTemplate(template); + } setEditingGlobalTemplate(null); setCurrentStep('dashboard'); }, []); - // Content grid state (per company) - const [contentData, setContentData] = useState>(() => { - return loadContentData() ?? {}; - }); + // Global Content Mesh state + const [globalContentMesh, setGlobalContentMesh] = useState({}); - 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; - }); + const updateGlobalContentMesh = useCallback((newMesh: any) => { + setGlobalContentMesh(newMesh); + if (window.electronAPI) { + window.electronAPI.fs.saveContentMesh(newMesh); + } }, []); // Studio initial data (passed to EditorProvider when entering studio) @@ -112,17 +98,58 @@ export default function App() { const [editorKey, setEditorKey] = useState(0); useCustomTooltips(); - usePersistence(companies); - useTemplatePersistence(globalTemplates); - const handleDesignChange = (key: keyof DesignMD, value: string | number | string[] | boolean) => { + // Load from FS on mount + React.useEffect(() => { + if (window.electronAPI) { + window.electronAPI.fs.getBrands().then((loadedBrands) => { + if (loadedBrands && loadedBrands.length > 0) { + setCompanies(loadedBrands); + } + }); + window.electronAPI.fs.getTemplates().then((loadedTemplates) => { + if (loadedTemplates && loadedTemplates.length > 0) { + setGlobalTemplates(loadedTemplates); + } + }); + window.electronAPI.fs.getContentMesh().then((mesh) => { + if (mesh) setGlobalContentMesh(mesh); + }); + // Sync workspace path to local express server + window.electronAPI.fs.getWorkspacePath().then((path) => { + if (path) { + fetch('/api/config/workspace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }).catch(err => console.warn('Failed to sync workspace path to server:', err)); + } + }); + } + }, []); + + const handleDesignChange = async (key: keyof DesignMD, value: string | number | string[] | boolean) => { + let updatedBrand: CompanyProfile | null = null; setDesignMD((prev) => { const newDesign = { ...prev, [key]: value }; if (currentCompanyId) { - setCompanies(prev2 => prev2.map(c => c.id === currentCompanyId ? { ...c, design: newDesign } : c)); + setCompanies(prev2 => { + const next = prev2.map(c => { + if (c.id === currentCompanyId) { + updatedBrand = { ...c, design: newDesign }; + return updatedBrand; + } + return c; + }); + return next; + }); } return newDesign; }); + + if (updatedBrand && window.electronAPI) { + await window.electronAPI.fs.saveBrand(updatedBrand); + } }; const enterStudio = (design: DesignMD, format: 'video' | 'image', elements: TimelineElement[], layers: TimelineLayer[], companyId?: string, projectId?: string | null) => { @@ -233,11 +260,19 @@ export default function App() { 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]); + setGlobalTemplates(prev => { + const next = [...prev, copy]; + if (window.electronAPI) window.electronAPI.fs.saveTemplate(copy); + return next; + }); }, []); const handleDeleteTemplate = useCallback((id: string) => { - setGlobalTemplates(prev => prev.filter(t => t.id !== id)); + setGlobalTemplates(prev => { + const newTemplates = prev.filter(t => t.id !== id); + if (window.electronAPI) window.electronAPI.fs.deleteTemplate(id); + return newTemplates; + }); }, []); const handleProducePro = useCallback((fieldData: Record) => { @@ -276,12 +311,14 @@ export default function App() { projects: [] }; setCompanies(prev => [...prev, newBrand]); + if (window.electronAPI) window.electronAPI.fs.saveBrand(newBrand); setCurrentCompanyId(newAppId); setDesignMD(newBrand.design); setCurrentStep('brand'); }} onDeleteBrand={(id) => { setCompanies(prev => prev.filter(c => c.id !== id)); + if (window.electronAPI) window.electronAPI.fs.deleteBrand(id); }} onDuplicateBrand={(id) => { const original = companies.find(c => c.id === id); @@ -296,6 +333,7 @@ export default function App() { socialLinks: original.socialLinks ? { ...original.socialLinks } : undefined, }; setCompanies(prev => [...prev, duplicate]); + if (window.electronAPI) window.electronAPI.fs.saveBrand(duplicate); }} onEditBrand={(design) => { const comp = companies.find(c => c.design === design); @@ -334,6 +372,7 @@ export default function App() { company={companies.find(c => c.id === currentCompanyId)!} handleCompanyChange={(company) => { setCompanies(prev => prev.map(c => c.id === company.id ? company : c)); + if (window.electronAPI) window.electronAPI.fs.saveBrand(company); }} designMD={designMD} handleDesignChange={handleDesignChange} @@ -342,15 +381,13 @@ export default function App() { /> )} - {currentStep === 'content-grid' && currentCompanyId && ( + {currentStep === 'content-grid' && ( 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); + companies={companies} + contentMesh={globalContentMesh} + onContentMeshChange={updateGlobalContentMesh} + onOpenProject={(projectId, companyId) => { + const comp = companies.find(c => c.id === companyId); if (comp) { const proj = comp.projects.find(p => p.id === projectId); if (proj) { @@ -386,7 +423,7 @@ export default function App() { initialLayers={studioInitialLayers} initialFormat={outputFormat} initialAspect={templateBuilderAspect} - brandContent={currentCompanyId ? (getContentForCompany(currentCompanyId).pieces || []) : []} + brandContent={[]} // TODO: Adapt if needed for global mesh editingBrandAsset={editingBrandAsset} >
diff --git a/src/components/BrandArchitecture.tsx b/src/components/BrandArchitecture.tsx index 5682d30..05ad17d 100644 --- a/src/components/BrandArchitecture.tsx +++ b/src/components/BrandArchitecture.tsx @@ -1,10 +1,11 @@ import React, { useState, useCallback } from 'react'; -import { Save, AlertCircle, Crown } from 'lucide-react'; +import { Save, AlertCircle, Crown, FolderOpen, Sparkles } 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 { BrandTabGenerated } from './brand/BrandTabGenerated'; import { BrandPreview } from './brand/BrandPreview'; import { Toast } from './ui/Toast'; @@ -22,6 +23,7 @@ const TABS = [ { id: 'visual', label: 'Visual y Colores', icon: '🎨' }, { id: 'typography', label: 'Tipografía', icon: '🔤' }, { id: 'media', label: 'Video y Audio', icon: '🎬' }, + { id: 'generated', label: 'Generados', icon: '✨' }, ] as const; type TabId = typeof TABS[number]['id']; @@ -59,7 +61,13 @@ export const BrandArchitecture: React.FC = ({ company, h }; - + const handleOpenFolder = async () => { + if (window.electronAPI && company?.id) { + const workspacePath = await window.electronAPI.fs.getWorkspacePath(); + const folderPath = `${workspacePath}/${company.id}`; + await window.electronAPI.fs.openFolder(folderPath); + } + }; return (
{/* ═══ Sticky Header: Title + Brand Identity ═══ */} @@ -120,6 +128,14 @@ export const BrandArchitecture: React.FC = ({ company, h
+ + {/* Save Button */} + {/* ── Zone 1 & 2: Templates + Brands (side by side) ── */} -
+
= ({
{/* ── Zone 3: Generate Content ── */} - 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} - /> +
+ 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} + /> +
diff --git a/src/components/TopHeader.tsx b/src/components/TopHeader.tsx index 1cc594a..718f5b0 100644 --- a/src/components/TopHeader.tsx +++ b/src/components/TopHeader.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { LayoutTemplate, Menu, Home, Settings, Download, ZoomIn, ZoomOut, X, CalendarDays, Sparkles, Play } from 'lucide-react'; +import { LayoutTemplate, Menu, Home, Settings, Download, ZoomIn, ZoomOut, X, CalendarDays, Sparkles, Play, FolderOpen } from 'lucide-react'; interface TopHeaderProps { currentStep: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form'; @@ -84,7 +84,7 @@ export const TopHeader: React.FC = ({ @@ -172,6 +172,17 @@ export const TopHeader: React.FC = ({ Editor Pro 🎛️ )} + + {currentStep !== 'content-grid' && ( + + )} {isStudio && ( diff --git a/src/components/brand/BrandTabGenerated.tsx b/src/components/brand/BrandTabGenerated.tsx new file mode 100644 index 0000000..b4573c0 --- /dev/null +++ b/src/components/brand/BrandTabGenerated.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { CompanyProfile } from '../../types'; +import { GeneratedMediaList } from '../content-grid/GeneratedMediaList'; + +interface BrandTabGeneratedProps { + company: CompanyProfile; +} + +export const BrandTabGenerated: React.FC = ({ company }) => { + return ( +
+
+

+ Contenido Generado +

+

+ Archivos renderizados y guardados para la marca {company.name}. +

+
+ +
+ ); +}; diff --git a/src/components/brand/BrandTabMedia.tsx b/src/components/brand/BrandTabMedia.tsx index 229c7ca..56b79a6 100644 --- a/src/components/brand/BrandTabMedia.tsx +++ b/src/components/brand/BrandTabMedia.tsx @@ -1,9 +1,10 @@ import React, { useCallback } from 'react'; import { Film, Volume2, Music, X, Upload, Wand2, Maximize2, Minimize2, Move, Pipette } from 'lucide-react'; -import { DesignMD } from '../../types'; +import { DesignMD, CompanyProfile } from '../../types'; import { FileDropZone } from '../ui/FileDropZone'; interface BrandTabMediaProps { + company: CompanyProfile; designMD: DesignMD; handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void; } @@ -15,7 +16,7 @@ interface BrandTabMediaProps { * All positioning, fit, duration, and blend controls live in the TemplateBuilder * (per-template segment configuration), avoiding collisions. */ -export const BrandTabMedia: React.FC void }> = ({ designMD, handleDesignChange, onEditAsset }) => { +export const BrandTabMedia: React.FC void }> = ({ company, designMD, handleDesignChange, onEditAsset }) => { /** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */ const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => { @@ -48,6 +49,7 @@ export const BrandTabMedia: React.FC { + let workspacePath = ''; + if (window.electronAPI) { + workspacePath = await window.electronAPI.fs.getWorkspacePath(); + } + const formData = new FormData(); formData.append('file', files[0]); + try { - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + let res; + if (workspacePath && company.id) { + formData.append('brandId', company.id); + formData.append('workspacePath', workspacePath); + res = await fetch('/api/upload/brand', { method: 'POST', body: formData }); + } else { + res = await fetch('/api/upload', { method: 'POST', body: formData }); + } + + if (!res.ok) throw new Error('Upload failed'); const data = await res.json(); if (data.url) handleDesignChange('brandAudioUrl', data.url); } catch (err) { @@ -184,6 +202,7 @@ export const BrandTabMedia: React.FC void; bgColor?: string | null; onBgColorChange?: (color: string | null) => void; -}> = ({ label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit, fit = 'cover', onFitChange, bgColor, onBgColorChange }) => { +}> = ({ company, label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit, fit = 'cover', onFitChange, bgColor, onBgColorChange }) => { const hasVideo = !!videoUrl && videoUrl.trim().length > 0; const colorInputRef = React.useRef(null); @@ -258,10 +277,25 @@ const VideoUploadSimple: React.FC<{ accept="video/*" label="Subir archivo" onFiles={async (files) => { + let workspacePath = ''; + if (window.electronAPI) { + workspacePath = await window.electronAPI.fs.getWorkspacePath(); + } + const formData = new FormData(); formData.append('file', files[0]); + try { - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + let res; + if (workspacePath && company.id) { + formData.append('brandId', company.id); + formData.append('workspacePath', workspacePath); + res = await fetch('/api/upload/brand', { method: 'POST', body: formData }); + } else { + res = await fetch('/api/upload', { method: 'POST', body: formData }); + } + + if (!res.ok) throw new Error('Upload failed'); const data = await res.json(); if (data.url) onUrlChange(data.url); } catch (err) { diff --git a/src/components/brand/BrandTabVisual.tsx b/src/components/brand/BrandTabVisual.tsx index 9af79f9..0539665 100644 --- a/src/components/brand/BrandTabVisual.tsx +++ b/src/components/brand/BrandTabVisual.tsx @@ -1,15 +1,17 @@ import React, { useCallback } from 'react'; import { Settings2, ImageIcon, Wand2 } from 'lucide-react'; -import { DesignMD } from '../../types'; +import { DesignMD, CompanyProfile } from '../../types'; import { FileDropZone } from '../ui/FileDropZone'; interface BrandTabVisualProps { + company: CompanyProfile; designMD: DesignMD; handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void; onEditAsset?: (type: keyof DesignMD, url: string) => void; } export const BrandTabVisual: React.FC = ({ + company, designMD, handleDesignChange, onEditAsset, @@ -19,9 +21,23 @@ export const BrandTabVisual: React.FC = ({ if (!file) return; try { + let workspacePath = ''; + if (window.electronAPI) { + workspacePath = await window.electronAPI.fs.getWorkspacePath(); + } + const formData = new FormData(); formData.append('file', file); - const res = await fetch('/api/upload', { method: 'POST', body: formData }); + + let res; + if (workspacePath && company.id) { + formData.append('brandId', company.id); + formData.append('workspacePath', workspacePath); + res = await fetch('/api/upload/brand', { method: 'POST', body: formData }); + } else { + res = await fetch('/api/upload', { method: 'POST', body: formData }); + } + if (!res.ok) throw new Error('Upload failed'); const data = await res.json(); handleDesignChange('logoUrl', data.url); diff --git a/src/components/content-grid/CalendarView.tsx b/src/components/content-grid/CalendarView.tsx index 1a31fdc..af7ad9a 100644 --- a/src/components/content-grid/CalendarView.tsx +++ b/src/components/content-grid/CalendarView.tsx @@ -1,14 +1,14 @@ -import React, { useState, useMemo, useCallback } from 'react'; -import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; -import { ContentPiece, ContentPillar } from '../../types'; -import { ContentCard } from './ContentCard'; +import React, { useState, useMemo } from 'react'; +import { ChevronLeft, ChevronRight, Image as ImageIcon, Play } from 'lucide-react'; +import { CompanyProfile } from '../../types'; +import { useDroppable } from '@dnd-kit/core'; interface CalendarViewProps { - pieces: ContentPiece[]; - pillars: ContentPillar[]; - onPieceClick: (piece: ContentPiece) => void; - onCreatePiece: (date: string) => void; - onDropPiece: (pieceId: string, newDate: string) => void; + contentMesh: any; + onContentMeshChange: (mesh: any) => void; + companies: CompanyProfile[]; + filterBrandId: string; + onSelectDate: (date: string) => void; } const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; @@ -17,73 +17,74 @@ const MONTHS_ES = [ '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 = ({ - pieces, - pillars, - onPieceClick, - onCreatePiece, - onDropPiece, + contentMesh, + onContentMeshChange, + companies, + filterBrandId, + onSelectDate, }) => { const [currentDate, setCurrentDate] = useState(new Date()); - const [dragOverDate, setDragOverDate] = useState(null); + const [calendarType, setCalendarType] = useState<'month' | 'week'>('month'); - const year = currentDate.getFullYear(); - const month = currentDate.getMonth(); + const yearNum = currentDate.getFullYear(); + const monthNum = currentDate.getMonth(); - // Generate calendar grid (6 weeks × 7 days) + // Generate calendar grid 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(); + if (calendarType === 'month') { + const firstDay = new Date(yearNum, monthNum, 1); + // Adjust so Monday = 0 + const startDow = (firstDay.getDay() + 6) % 7; + const daysInMonth = new Date(yearNum, monthNum + 1, 0).getDate(); - const days: { date: Date; isCurrentMonth: boolean }[] = []; + 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 = {}; - pieces.forEach(p => { - if (p.scheduledDate) { - const key = p.scheduledDate; - if (!map[key]) map[key] = []; - map[key].push(p); + // Previous month fill + const prevMonthDays = new Date(yearNum, monthNum, 0).getDate(); + for (let i = startDow - 1; i >= 0; i--) { + days.push({ + date: new Date(yearNum, monthNum - 1, prevMonthDays - i), + isCurrentMonth: false, + }); } - }); - return map; - }, [pieces]); + + // Current month + for (let d = 1; d <= daysInMonth; d++) { + days.push({ + date: new Date(yearNum, monthNum, 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(yearNum, monthNum + 1, d), + isCurrentMonth: false, + }); + } + + return days; + } else { + // Week view + const firstDayOfWeek = new Date(currentDate); + firstDayOfWeek.setDate(currentDate.getDate() - ((currentDate.getDay() + 6) % 7)); + firstDayOfWeek.setHours(0,0,0,0); + + const days: { date: Date; isCurrentMonth: boolean }[] = []; + for (let i = 0; i < 7; i++) { + const d = new Date(firstDayOfWeek); + d.setDate(d.getDate() + i); + days.push({ + date: d, + isCurrentMonth: d.getMonth() === currentDate.getMonth() + }); + } + return days; + } + }, [currentDate, yearNum, monthNum, calendarType]); const toDateKey = (date: Date) => { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; @@ -96,41 +97,56 @@ export const CalendarView: React.FC = ({ 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); + const goToPrev = () => { + if (calendarType === 'month') { + setCurrentDate(new Date(yearNum, monthNum - 1, 1)); + } else { + const prevWeek = new Date(currentDate); + prevWeek.setDate(currentDate.getDate() - 7); + setCurrentDate(prevWeek); } - setDragOverDate(null); - }, [onDropPiece]); - - const handleDragStart = useCallback((e: React.DragEvent, piece: ContentPiece) => { - e.dataTransfer.setData('text/piece-id', piece.id); - e.dataTransfer.effectAllowed = 'move'; - }, []); + }; + + const goToNext = () => { + if (calendarType === 'month') { + setCurrentDate(new Date(yearNum, monthNum + 1, 1)); + } else { + const nextWeek = new Date(currentDate); + nextWeek.setDate(currentDate.getDate() + 7); + setCurrentDate(nextWeek); + } + }; + const goToToday = () => setCurrentDate(new Date()); return (
{/* Calendar Header */}
-

- {MONTHS_ES[month]} {year} +

+ {MONTHS_ES[monthNum]} {yearNum}

+
+ + +
{/* Calendar grid */} -
+
{calendarDays.map(({ date, isCurrentMonth }, idx) => { const dateKey = toDateKey(date); - const dayPieces = piecesByDate[dateKey] || []; const today = isToday(date); - const isDragOver = dragOverDate === dateKey; + + const [yyyy, mm, dd] = dateKey.split('-'); + const dayData = contentMesh?.[yyyy]?.[mm]?.[dd] || { images: [], videos: [] }; + const itemsCount = (dayData.images?.length || 0) + (dayData.videos?.length || 0); return ( -
handleDragOver(e, dateKey)} - onDragLeave={() => setDragOverDate(null)} - onDrop={(e) => handleDrop(e, dateKey)} - > - {/* Day number */} -
- - {date.getDate()} - - {isCurrentMonth && ( - - )} -
- - {/* Content pieces */} -
- {dayPieces.slice(0, 3).map(piece => ( - p.id === piece.pillarId)} - onClick={onPieceClick} - compact - draggable - onDragStart={handleDragStart} - /> - ))} - {dayPieces.length > 3 && ( - - +{dayPieces.length - 3} más - - )} -
-
+ date={date} + dateKey={dateKey} + isCurrentMonth={isCurrentMonth} + today={today} + itemsCount={itemsCount} + dayData={dayData} + filterBrandId={filterBrandId} + companies={companies} + onClick={() => onSelectDate(dateKey)} + /> ); })}
); }; + +interface DayCellProps { + date: Date; + dateKey: string; + isCurrentMonth: boolean; + today: boolean; + itemsCount: number; + dayData: { images: any[]; videos: any[] }; + filterBrandId: string; + companies: CompanyProfile[]; + onClick: () => void; +} + +const DayCell: React.FC = ({ + date, + dateKey, + isCurrentMonth, + today, + dayData, + filterBrandId, + companies, + onClick +}) => { + const { setNodeRef, isOver } = useDroppable({ + id: dateKey, + }); + + const filteredVideos = filterBrandId ? (dayData.videos || []).filter(v => v.mark_id === filterBrandId) : (dayData.videos || []); + const filteredImages = filterBrandId ? (dayData.images || []).filter(i => i.mark_id === filterBrandId) : (dayData.images || []); + const itemsCount = filteredVideos.length + filteredImages.length; + + const getBrandName = (id: string) => companies.find(c => c.id === id)?.name || ''; + + return ( +
+
+ + {date.getDate()} + + {itemsCount > 0 && ( + + {itemsCount} + + )} +
+ +
+ {filteredVideos.slice(0, 2).map((v, i) => { + const displayName = filterBrandId ? v.original_name : `${getBrandName(v.mark_id)} - ${v.original_name}`; + return ( +
+ {displayName} +
+ ); + })} + {filteredImages.slice(0, 2).map((img, i) => { + const displayName = filterBrandId ? img.original_name : `${getBrandName(img.mark_id)} - ${img.original_name}`; + return ( +
+ {displayName} +
+ ); + })} + {itemsCount > 4 && ( +
+ +{itemsCount - 4} más +
+ )} +
+
+ ); +}; + diff --git a/src/components/content-grid/ContentGridView.tsx b/src/components/content-grid/ContentGridView.tsx index be15137..4337128 100644 --- a/src/components/content-grid/ContentGridView.tsx +++ b/src/components/content-grid/ContentGridView.tsx @@ -1,321 +1,168 @@ -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 React, { useState } from 'react'; +import { DndContext, DragEndEvent } from '@dnd-kit/core'; +import { CompanyProfile } from '../../types'; +import { ContentMeshSidebar } from './ContentMeshSidebar'; 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'; +import { DailyTimelineView } from './DailyTimelineView'; interface ContentGridViewProps { - company: CompanyProfile; - pieces: ContentPiece[]; - pillars: ContentPillar[]; - onPiecesChange: (pieces: ContentPiece[]) => void; - onPillarsChange: (pillars: ContentPillar[]) => void; - onOpenProject: (projectId: string) => void; + companies: CompanyProfile[]; + contentMesh: any; + onContentMeshChange: (mesh: any) => void; + onOpenProject: (projectId: string, companyId: string) => void; } -/** - * Main content grid view with three visualization modes. - * Orchestrates Calendar, Grid, and List views with shared filters. - */ export const ContentGridView: React.FC = ({ - company, - pieces, - pillars, - onPiecesChange, - onPillarsChange, + companies, + contentMesh, + onContentMeshChange, onOpenProject, }) => { - const [viewMode, setViewMode] = useState('calendar'); - const [showSettings, setShowSettings] = useState(false); - const [editingPiece, setEditingPiece] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); - const [createDate, setCreateDate] = useState(); + const [viewMode, setViewMode] = useState<'calendar' | 'timeline'>('calendar'); + const [selectedDate, setSelectedDate] = useState(null); + const [filterBrandId, setFilterBrandId] = useState(''); - // Grid view state - const [gridPlatform, setGridPlatform] = useState('instagram'); + const selectedFilterBrand = filterBrandId ? companies.find(c => c.id === filterBrandId) : null; + const title = selectedFilterBrand ? `Malla de Contenidos - ${selectedFilterBrand.name}` : 'Malla de Contenidos Global'; - // Filters - const [selectedPillar, setSelectedPillar] = useState(null); - const [selectedStatus, setSelectedStatus] = useState(null); - const [selectedPlatform, setSelectedPlatform] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return; - // 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; + if (active.data.current?.type === 'generated-media') { + const { brandId, mediaItem } = active.data.current; + + let targetDate = over.id as string; + let targetStatus = 'draft'; // default status + let targetTime = '12:00'; // default time + + if (String(over.id).startsWith('timeline-')) { + // Dropped into a specific time slot + if (!selectedDate) return; + targetDate = selectedDate; + targetTime = String(over.id).replace('timeline-', ''); } - return true; - }); - }, [pieces, selectedPillar, selectedStatus, selectedPlatform, searchQuery]); + + // Simple validation for YYYY-MM-DD + const dateParts = targetDate.split('-'); + if (dateParts.length !== 3) return; + const [year, month, day] = dateParts; - // 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]); + const newMesh = JSON.parse(JSON.stringify(contentMesh || {})); // deep copy - // Handlers - const handleCreatePiece = useCallback((date?: string) => { - setCreateDate(date); - setEditingPiece(null); - setShowCreateModal(true); - }, []); + if (!newMesh[year]) newMesh[year] = {}; + if (!newMesh[year][month]) newMesh[year][month] = {}; + if (!newMesh[year][month][day]) newMesh[year][month][day] = { images: [], videos: [] }; - 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'; + const format = mediaItem.type === 'video' ? 'videos' : 'images'; + + newMesh[year][month][day][format].push({ + id: `mesh-${Date.now()}`, + mark_id: brandId, + file_path: mediaItem.path, + original_name: mediaItem.name || (mediaItem.path.split('/').pop() || mediaItem.path), + status: targetStatus, + time: targetTime, + platforms: [] + }); + + onContentMeshChange(newMesh); + } + else if (active.data.current?.type === 'timeline-item' || active.data.current?.type === 'kanban-item') { + // Reordering within the timeline + const { item } = active.data.current; + if (!selectedDate) return; + + const newMesh = JSON.parse(JSON.stringify(contentMesh)); + const [year, month, day] = selectedDate.split('-'); + const dayData = newMesh[year]?.[month]?.[day] || { images: [], videos: [] }; + + const isVideo = dayData.videos?.some((v: any) => v.id === item.id); + const isImage = dayData.images?.some((v: any) => v.id === item.id); + const format = isVideo ? 'videos' : (isImage ? 'images' : null); + if (!format) return; + + const targetArray = newMesh[year][month][day][format]; + if (!targetArray) return; + + const idx = targetArray.findIndex((v: any) => v.id === item.id); + if (idx === -1) return; + + if (String(over.id).startsWith('timeline-')) { + const targetTime = String(over.id).replace('timeline-', ''); + targetArray[idx].time = targetTime; + onContentMeshChange(newMesh); } - 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 ( -
- {/* Background pattern */} -
+ +
+ {/* Background pattern */} +
-
- {/* ═══ Header ═══ */} -
+ {/* Global Header (Full Width) */} +
+ {/* Left: Brand Selector */}
-
- -
-
-

Malla de Contenidos

-

- {company.name} · {filteredPieces.length} de {pieces.length} piezas -

-
+ +
-
- {/* Stats mini-bar */} -
- } color="#a78bfa" /> - } color="#60a5fa" /> - } color="#22c55e" /> -
- - {/* Settings button */} - - - {/* New content CTA */} - + {/* Right: Title */} +
+

{title}

+

Arrastra el contenido finalizado al calendario.

- {/* ═══ Settings Panel (Pillar Manager) ═══ */} - {showSettings && ( -
- + {/* Content Area */} +
+ {/* Sidebar */} + + + {/* Main Area */} +
+ {viewMode === 'calendar' ? ( + { + setSelectedDate(date); + setViewMode('timeline'); + }} + /> + ) : selectedDate ? ( + { + setSelectedDate(null); + setViewMode('calendar'); + }} + /> + ) : null}
- )} - - {/* ═══ View Mode Toggle + Filters ═══ */} -
- {/* Filters */} -
- -
- - {/* View toggle */} -
- {([ - { id: 'calendar' as ViewMode, icon: , label: 'Calendario' }, - { id: 'grid' as ViewMode, icon: , label: 'Grid' }, - { id: 'list' as ViewMode, icon: , label: 'Lista' }, - ]).map(v => ( - - ))} -
-
- - {/* ═══ View Content ═══ */} -
- {viewMode === 'calendar' && ( - handleCreatePiece(date)} - onDropPiece={handleDropPiece} - /> - )} - {viewMode === 'grid' && ( - - )} - {viewMode === 'list' && ( - - )} - - {/* Empty state */} - {filteredPieces.length === 0 && pieces.length === 0 && ( -
-
- -
-

Tu malla está vacía

-

- Empieza a planificar tu contenido creando piezas y organizándolas en el calendario -

- -
- )}
- - {/* ═══ Content Detail Modal ═══ */} - {showCreateModal && ( - { setShowCreateModal(false); setEditingPiece(null); setCreateDate(undefined); }} - onOpenProject={onOpenProject} - /> - )} -
+ ); }; - -/** Mini stat pill for the header */ -const StatPill: React.FC<{ label: string; value: number; icon: React.ReactNode; color: string }> = ({ - label, value, icon, color, -}) => ( -
- {icon} - {value} - {label} -
-); diff --git a/src/components/content-grid/ContentMeshSidebar.tsx b/src/components/content-grid/ContentMeshSidebar.tsx new file mode 100644 index 0000000..aacea3c --- /dev/null +++ b/src/components/content-grid/ContentMeshSidebar.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { CompanyProfile } from '../../types'; +import { Search } from 'lucide-react'; +import { GeneratedMediaList } from './GeneratedMediaList'; + +interface ContentMeshSidebarProps { + companies: CompanyProfile[]; + filterBrandId: string; +} + +export const ContentMeshSidebar: React.FC = ({ companies, filterBrandId }) => { + const [searchQuery, setSearchQuery] = useState(''); + + return ( +
+ {/* Header */} +
+

Contenido Generado

+ +
{/* Search */} +
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full bg-neutral-950 border border-neutral-800 rounded-lg pl-9 pr-3 py-2 text-sm text-white focus:border-violet-500 focus:outline-none transition-colors" + /> +
+
+
+ + {/* Media List */} +
+ +
+
+ ); +}; diff --git a/src/components/content-grid/DailyTimelineView.tsx b/src/components/content-grid/DailyTimelineView.tsx new file mode 100644 index 0000000..d5b8ba6 --- /dev/null +++ b/src/components/content-grid/DailyTimelineView.tsx @@ -0,0 +1,391 @@ +import React, { useState, useEffect } from 'react'; +import { ChevronLeft, Play, Image as ImageIcon, FolderOpen, Instagram, Music, Youtube, Facebook, Twitter, ChevronDown, ChevronRight, Moon } from 'lucide-react'; +import { useDroppable, useDraggable } from '@dnd-kit/core'; +import { CompanyProfile } from '../../types'; + +interface DailyTimelineViewProps { + dateKey: string; + contentMesh: any; + onContentMeshChange: (mesh: any) => void; + companies: CompanyProfile[]; + filterBrandId?: string; + onClose: () => void; +} + +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const SLOT_HEIGHT = 80; // 80px per hour +const HALF_SLOT = SLOT_HEIGHT / 2; + +const PLATFORMS = [ + { id: 'instagram', icon: Instagram, color: 'text-pink-500', bg: 'bg-pink-500/20' }, + { id: 'tiktok', icon: Music, color: 'text-cyan-400', bg: 'bg-cyan-500/20' }, + { id: 'youtube', icon: Youtube, color: 'text-red-500', bg: 'bg-red-500/20' }, + { id: 'facebook', icon: Facebook, color: 'text-blue-500', bg: 'bg-blue-500/20' }, + { id: 'twitter', icon: Twitter, color: 'text-neutral-300', bg: 'bg-neutral-500/20' }, +]; + +export const DailyTimelineView: React.FC = ({ + dateKey, + contentMesh, + onContentMeshChange, + companies, + filterBrandId, + onClose +}) => { + const [year, month, day] = dateKey.split('-'); + const dayData = contentMesh?.[year]?.[month]?.[day] || { images: [], videos: [] }; + + const allItems = [...(dayData.videos || []), ...(dayData.images || [])] + .filter(i => !filterBrandId || i.mark_id === filterBrandId); + + const selectedBrand = filterBrandId ? companies.find(c => c.id === filterBrandId) : null; + const getBrandName = (brandId: string) => companies.find(c => c.id === brandId)?.name || 'Marca desconocida'; + + const [workspacePath, setWorkspacePath] = useState(''); + const scrollRef = React.useRef(null); + + const [isMorningCollapsed, setIsMorningCollapsed] = useState(true); + const startHour = isMorningCollapsed ? 8 : 0; + + useEffect(() => { + if (window.electronAPI) { + window.electronAPI.fs.getWorkspacePath().then(setWorkspacePath); + } + }, []); + + useEffect(() => { + if (scrollRef.current) { + // Offset so 12 PM is clearly visible + const targetScroll = (12 - startHour) * SLOT_HEIGHT - 20; + scrollRef.current.scrollTop = Math.max(0, targetScroll); + } + }, [startHour]); + + const getUrl = (absolutePath: string) => { + if (!workspacePath) return ''; + const relPath = absolutePath.replace(workspacePath, ''); + return `http://localhost:3000/workspace${relPath.startsWith('/') ? '' : '/'}${relPath}`; + }; + + const handleOpenPath = async (filePath: string) => { + if (window.electronAPI) { + await window.electronAPI.fs.showItemInFolder(filePath); + } + }; + + const updateItem = (itemId: string, updater: (item: any) => void) => { + const newMesh = JSON.parse(JSON.stringify(contentMesh)); + const isVideo = dayData.videos?.some((v: any) => v.id === itemId); + const isImage = dayData.images?.some((v: any) => v.id === itemId); + const format = isVideo ? 'videos' : (isImage ? 'images' : null); + + if (format) { + const targetArray = newMesh[year][month][day][format]; + const idx = targetArray.findIndex((v: any) => v.id === itemId); + if (idx !== -1) { + updater(targetArray[idx]); + onContentMeshChange(newMesh); + } + } + }; + + const handleToggleStatus = (itemId: string, currentStatus: string) => { + const nextStatus = currentStatus === 'draft' ? 'scheduled' : currentStatus === 'scheduled' ? 'posted' : 'draft'; + updateItem(itemId, item => { item.status = nextStatus; }); + }; + + const handleTogglePlatform = (itemId: string, platform: string) => { + updateItem(itemId, item => { + const current = item.platforms || []; + if (current.includes(platform)) { + item.platforms = current.filter((p: string) => p !== platform); + } else { + item.platforms = [...current, platform]; + } + }); + }; + + // Metrics for header + const totalPosts = allItems.length; + let platformCounts: Record = {}; + allItems.forEach(i => { + const pList = i.platforms || []; + pList.forEach((p: string) => { + platformCounts[p] = (platformCounts[p] || 0) + 1; + }); + }); + + // Date formatting for header + const dateObj = new Date(`${year}-${month}-${day}T12:00:00`); // mid-day to avoid timezone shifting + const formatter = new Intl.DateTimeFormat('es-ES', { weekday: 'long', day: 'numeric', month: 'short' }); + const formattedDate = formatter.format(dateObj).replace(',', ''); + const capitalizedDate = formattedDate.charAt(0).toUpperCase() + formattedDate.slice(1); + + return ( +
+ {/* Header */} +
+
+ + +
+ +
+ {selectedBrand && selectedBrand.logo && ( + {selectedBrand.name} + )} +
+

+ {capitalizedDate} {selectedBrand ? `· ${selectedBrand.name}` : ''} +

+
+ {totalPosts} publicaciones programadas +
+ {Object.entries(platformCounts).map(([plat, count]) => { + const pConfig = PLATFORMS.find(p => p.id === plat); + if (!pConfig) return null; + const Icon = pConfig.icon; + return ( +
+ {count} +
+ ); + })} +
+
+
+
+
+
+ + {/* Timeline Scroll Area */} +
+
+ + {/* Collapsed Morning Bar */} +
+ +
+ +
+ {/* Time axis background lines */} + {HOURS.slice(startHour).map(hour => ( +
+
+ {hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`} +
+
+ ))} + + {/* Droppable Slots (every 30 mins) */} +
+ {HOURS.slice(startHour).map(hour => ( + + + + + ))} +
+ + {/* Draggable Items */} +
+ {allItems.map((item, index) => { + // Filter out items in collapsed morning + const [h] = (item.time || '12:00').split(':').map(Number); + if (isMorningCollapsed && h < 8) return null; + + return ( + + ); + })} +
+
+ +
+
+
+ ); +}; + +const PEAK_HOURS = ['12:00', '19:00']; + +const TimeSlot: React.FC<{ timeStr: string; height: number }> = ({ timeStr, height }) => { + const { setNodeRef, isOver } = useDroppable({ id: `timeline-${timeStr}` }); + const isPeak = PEAK_HOURS.includes(timeStr); + + return ( +
+ {isPeak && !isOver && ( +
+ 🔥 Buena hora +
+ )} + {isOver && ( +
+ Suelta el contenido aquí +
+ )} +
+ ); +}; + +const TimelineItem: React.FC<{ + item: any; + brandName: string; + getUrl: (path: string) => string; + onOpenFolder: (path: string) => void; + onToggleStatus: (id: string, currentStatus: string) => void; + onTogglePlatform: (id: string, platform: string) => void; + startHour: number; + overlapOffset: number; +}> = ({ item, brandName, getUrl, onOpenFolder, onToggleStatus, onTogglePlatform, startHour, overlapOffset }) => { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: `timeline-${item.id}`, + data: { + type: 'timeline-item', + item + } + }); + + // Calculate position + let topPosition = 0; + if (item.time) { + const [h, m] = item.time.split(':').map(Number); + topPosition = (h - startHour + m / 60) * SLOT_HEIGHT; + } + + const statusConfig = { + draft: { label: 'Borrador', color: 'text-neutral-400 bg-neutral-800 border-neutral-700 hover:bg-neutral-700' }, + scheduled: { label: 'Programado', color: 'text-blue-400 bg-blue-900/30 border-blue-800 hover:bg-blue-900/50' }, + posted: { label: 'Publicado', color: 'text-green-400 bg-green-900/30 border-green-800 hover:bg-green-900/50' }, + }; + + const currentStatus = item.status || 'draft'; + const statusUI = statusConfig[currentStatus as keyof typeof statusConfig] || statusConfig.draft; + const isPosted = currentStatus === 'posted'; + + const style = { + top: `${topPosition}px`, + transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined, + zIndex: isDragging ? 50 : (isPosted ? 10 : 20), + opacity: isDragging ? 0.9 : (isPosted ? 0.6 : 1), + }; + + const isVideo = item.file_path.match(/\.(mp4|webm|mov)$/i); + const fileUrl = getUrl(item.file_path); + const currentPlatforms = item.platforms || []; + + return ( +
+ {/* Thumbnail Area - acts as drag handle */} +
+ {isVideo ? ( +
+ + {/* Info Area */} +
+
+
+ {item.time || '12:00'} + {/* Platform toggles inline */} +
+ {PLATFORMS.map(p => { + const Icon = p.icon; + const isActive = currentPlatforms.includes(p.id); + return ( + + ) + })} +
+
+ + +
+ +
+ +

+ {item.original_name} +

+
+
+
+ ); +}; diff --git a/src/components/content-grid/GeneratedMediaList.tsx b/src/components/content-grid/GeneratedMediaList.tsx new file mode 100644 index 0000000..3f07f54 --- /dev/null +++ b/src/components/content-grid/GeneratedMediaList.tsx @@ -0,0 +1,270 @@ +import React, { useEffect, useState } from 'react'; +import { Play, Image as ImageIcon, FolderOpen, Edit2, Check, X } from 'lucide-react'; +import { useDraggable } from '@dnd-kit/core'; + +export interface MediaItem { + path: string; + date: string; + name: string; + type: 'video' | 'image'; +} + +interface GeneratedMediaListProps { + brandId?: string; + companies?: any[]; + searchQuery?: string; + draggable?: boolean; +} + +const DraggableMediaCard: React.FC<{ + item: MediaItem; + brandId: string; + draggable: boolean; + getUrl: (path: string) => string; + onOpenFolder: (path: string) => void; + onRename: (oldPath: string, newName: string) => Promise; +}> = ({ item, brandId, draggable, getUrl, onOpenFolder, onRename }) => { + const fileName = item.path.split('/').pop() || item.path; + const displayName = item.name || fileName; + + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(displayName); + const [isSaving, setIsSaving] = useState(false); + + // dnd-kit logic + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: `media-${item.path}`, + data: { + type: 'generated-media', + brandId, + mediaItem: item + }, + disabled: !draggable || isEditing, + }); + + const style = transform ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + zIndex: isDragging ? 50 : 1, + opacity: isDragging ? 0.8 : 1, + } : undefined; + + const handleSaveRename = async () => { + if (!editName.trim() || editName === displayName) { + setIsEditing(false); + return; + } + setIsSaving(true); + await onRename(item.path, editName); + setIsSaving(false); + setIsEditing(false); + }; + + return ( +
+ {/* Thumbnail Area - acts as drag handle if draggable */} +
+ {item.type === 'video' ? ( +
+ + {/* Info Area */} +
+
+
+ {isEditing ? ( +
+ setEditName(e.target.value)} + className="w-full bg-neutral-950 text-white text-sm px-2 py-1 rounded border border-violet-500/50 outline-none" + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') handleSaveRename(); + if (e.key === 'Escape') { + setEditName(displayName); + setIsEditing(false); + } + }} + disabled={isSaving} + /> + + +
+ ) : ( +
+

+ {displayName} +

+ +
+ )} +

+ {new Date(item.date).toLocaleString()} +

+
+ {!isEditing && ( + + )} +
+
+
+ ); +}; + +export const GeneratedMediaList: React.FC = ({ brandId, companies, searchQuery, draggable = false }) => { + const [media, setMedia] = useState([]); + const [workspacePath, setWorkspacePath] = useState(''); + const [loading, setLoading] = useState(true); + + const fetchMedia = async () => { + if (window.electronAPI) { + setLoading(true); + try { + const wp = await window.electronAPI.fs.getWorkspacePath(); + setWorkspacePath(wp); + + let allMedia: MediaItem[] = []; + const brandsToFetch = brandId ? [brandId] : (companies?.map(c => c.id) || []); + + for (const bid of brandsToFetch) { + try { + const videos = await window.electronAPI.fs.getGeneratedMedia(bid, 'video'); + const images = await window.electronAPI.fs.getGeneratedMedia(bid, 'image'); + + allMedia = [ + ...allMedia, + ...videos.map((v: any) => ({ ...v, type: 'video' as const, brandId: bid })), + ...images.map((img: any) => ({ ...img, type: 'image' as const, brandId: bid })) + ]; + } catch (e) { + console.error(`Error fetching media for brand ${bid}`, e); + } + } + + allMedia.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + setMedia(allMedia); + } catch (err) { + console.error('Error fetching generated media:', err); + } + setLoading(false); + } else { + setMedia([]); + setLoading(false); + } + }; + + useEffect(() => { + fetchMedia(); + }, [brandId]); + + const handleOpenPath = async (filePath: string) => { + if (window.electronAPI) { + await window.electronAPI.fs.showItemInFolder(filePath); + } + }; + + const handleRename = async (oldPath: string, newName: string) => { + if (!window.electronAPI || !brandId) return; + + // Find the item to know its type + const item = media.find(m => m.path === oldPath); + if (!item) return; + + try { + const newPath = await window.electronAPI.fs.renameGeneratedMedia(brandId, item.type, oldPath, newName); + if (newPath) { + await fetchMedia(); + } + } catch (e) { + console.error("Rename failed", e); + } + }; + + const getUrl = (absolutePath: string) => { + if (!workspacePath) return ''; + const relPath = absolutePath.replace(workspacePath, ''); + return `http://localhost:3000/workspace${relPath.startsWith('/') ? '' : '/'}${relPath}`; + }; + + if (loading) { + return
Cargando...
; + } + + + const filteredMedia = media.filter(item => { + if (!searchQuery) return true; + const name = item.name || item.path.split('/').pop() || ''; + return name.toLowerCase().includes(searchQuery.toLowerCase()); + }); + + if (filteredMedia.length === 0) { + return ( +
+ No se encontró contenido. +
+ ); + } + + return ( +
+ {filteredMedia.map((item, idx) => ( + + ))} +
+ ); +}; diff --git a/src/components/dashboard/BatchDataPanel.tsx b/src/components/dashboard/BatchDataPanel.tsx index cced09b..94699f2 100644 --- a/src/components/dashboard/BatchDataPanel.tsx +++ b/src/components/dashboard/BatchDataPanel.tsx @@ -21,9 +21,11 @@ interface BatchDataPanelProps { templateFormat: 'video' | 'image'; onSetBackgrounds: (files: File[]) => void; onUpdateField: (index: number, fieldId: string, value: string) => void; + onUpdateVariation: (index: number, variationId: string | null) => void; onImportCSV: (file: File) => Promise<{ matched: number; unmatched: number }>; onRemovePiece: (index: number) => void; backgroundFiles: File[]; + availableVariations: { id: string; name: string }[]; } /** Get only text-type editable slots (for table columns) */ @@ -38,9 +40,11 @@ export const BatchDataPanel: React.FC = ({ templateFormat, onSetBackgrounds, onUpdateField, + onUpdateVariation, onImportCSV, onRemovePiece, backgroundFiles, + availableVariations, }) => { const bgInputRef = useRef(null); const csvInputRef = useRef(null); @@ -145,7 +149,7 @@ export const BatchDataPanel: React.FC = ({
{/* ── Text Data Table ── */} - {textSlots.length > 0 && N > 0 && ( + {(textSlots.length > 0 || availableVariations.length > 0) && N > 0 && (
{/* Table header with CSV import */}
@@ -185,11 +189,14 @@ export const BatchDataPanel: React.FC = ({
'1fr').join(' ')} 28px`, + gridTemplateColumns: `36px 90px ${availableVariations.length > 0 ? '100px ' : ''}${textSlots.map(() => '1fr').join(' ')} 28px`, }} >
#
Fondo
+ {availableVariations.length > 0 && ( +
Variación
+ )} {textSlots.map(({ field }) => (
{field.label} @@ -208,7 +215,7 @@ export const BatchDataPanel: React.FC = ({ hasErrors ? 'bg-red-500/5' : 'bg-neutral-800/20' }`} style={{ - gridTemplateColumns: `36px 90px ${textSlots.map(() => '1fr').join(' ')} 28px`, + gridTemplateColumns: `36px 90px ${availableVariations.length > 0 ? '100px ' : ''}${textSlots.map(() => '1fr').join(' ')} 28px`, }} > {/* Row number */} @@ -223,6 +230,22 @@ export const BatchDataPanel: React.FC = ({
+ {/* Variation Selector */} + {availableVariations.length > 0 && ( +
+ +
+ )} + {/* Text fields */} {textSlots.map(({ field }) => { const val = piece.fieldData[field.id] || ''; @@ -279,8 +302,8 @@ export const BatchDataPanel: React.FC = ({
)} - {/* ── Empty state (no text fields) ── */} - {textSlots.length === 0 && N > 0 && ( + {/* ── Empty state (no text fields and no variations) ── */} + {textSlots.length === 0 && availableVariations.length === 0 && N > 0 && (

Esta plantilla no tiene campos de texto editables. diff --git a/src/components/dashboard/GenerateZone.tsx b/src/components/dashboard/GenerateZone.tsx index 3503cce..6f51700 100644 --- a/src/components/dashboard/GenerateZone.tsx +++ b/src/components/dashboard/GenerateZone.tsx @@ -28,56 +28,66 @@ export const GenerateZone: React.FC = ({ const canGenerate = !!selectedTemplate && !!selectedBrand; return ( -

- {/* Header */} -
-
- +
+ {/* Left: Info */} +
+
+
+ +
+
+

Generar contenido

+

+ Arrastra una plantilla y una marca +

+
-

Generar contenido

-

- Arrastra una plantilla y una marca, o toca para elegir. -

- {/* Slots row */} -
+ {/* Middle: Slots row */} +
{/* Template slot */} - +
+ +
{/* × separator */}
- × + ×
{/* Brand slot */} - +
+ +
+
- {/* Generate button */} + {/* Right: Generate button */} +
diff --git a/src/components/dashboard/ProductionForm.tsx b/src/components/dashboard/ProductionForm.tsx index e14bf30..51c6e7d 100644 --- a/src/components/dashboard/ProductionForm.tsx +++ b/src/components/dashboard/ProductionForm.tsx @@ -18,7 +18,7 @@ import { migrateExpressFields } from '../../context/TemplateBuilderContext'; import { useBatchProduction } from '../../hooks/useBatchProduction'; import { useVideoDurations } from '../../hooks/useVideoDurations'; import { BatchDataPanel } from './BatchDataPanel'; -import { exportBatchAsZip, BatchExportProgress } from '../../utils/batchExporter'; +import { exportBatchToDisk, BatchExportProgress } from '../../utils/batchExporter'; interface ProductionFormProps { template: ExpressTemplate; @@ -111,11 +111,23 @@ export const ProductionForm: React.FC = ({ const totalDuration = getTemplateDuration(template, videoDurations, designMD); const totalFrames = Math.max(30, totalDuration * fps); + // ─── Variations ─── + const availableVariations = useMemo(() => { + for (const scene of template.scenes) { + if (scene.variations && scene.variations.length > 0) { + return scene.variations; + } + } + return []; + }, [template]); + + const [activeVariationId, setActiveVariationId] = useState(null); + // ─── 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, videoDurations); + const result = compileExpressToTimeline(template, fieldData, designMD, brand, videoDurations, activeVariationId || undefined); result.elements = result.elements.map(el => { const fieldId = el.sourceFieldId; const fitOverride = fieldId ? mediaFits[fieldId] : undefined; @@ -130,7 +142,7 @@ export const ProductionForm: React.FC = ({ }); return result; }, - [showExportModal, template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations] + [showExportModal, template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations, activeVariationId] ); // ─── Collect all TemplateFields across all scenes ─── @@ -251,7 +263,7 @@ export const ProductionForm: React.FC = ({ setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' }); try { - await exportBatchAsZip( + await exportBatchToDisk( batch.pieces, template, brand, @@ -277,7 +289,7 @@ export const ProductionForm: React.FC = ({ } // 2. Compile to timeline elements - const compiled = compileExpressToTimeline(template, fd, designMD, brand, videoDurations); + const compiled = compileExpressToTimeline(template, fd, designMD, brand, videoDurations, piece.variationId); // 3. Apply fit overrides compiled.elements = compiled.elements.map(el => { @@ -331,6 +343,7 @@ export const ProductionForm: React.FC = ({ layers: compiled.layers, brandVisibility: { logo: false, frame: false, background: true }, outputFormat: 'video', + brandId: brand.id, }); } }, [batch.pieces, template, backgroundFieldId, designMD, brand, videoDurations, mediaFits, containBgColors, startExport]); @@ -412,9 +425,11 @@ export const ProductionForm: React.FC = ({ templateFormat={template.format} onSetBackgrounds={batch.setBackgroundFiles} onUpdateField={batch.updatePieceField} + onUpdateVariation={batch.updatePieceVariation} onImportCSV={batch.importCSV} onRemovePiece={batch.removePiece} backgroundFiles={batch.backgroundFiles} + availableVariations={availableVariations} /> ) : ( /* ── SINGLE MODE: Original form ── */ @@ -433,6 +448,23 @@ export const ProductionForm: React.FC = ({

+ {/* Variation selector */} + {availableVariations.length > 0 && ( +
+ + +
+ )} + {/* Scrollable fields */}
{/* ── Segment upload fields (form-sourced intro/outro) ── */} @@ -686,6 +718,7 @@ export const ProductionForm: React.FC = ({ onSceneChange={setActiveSceneId} playerRef={playerRef} videoDurations={videoDurations} + variationId={batch.isBatchMode ? (batch.pieces[activeBatchPieceIndex]?.variationId || undefined) : (activeVariationId || undefined)} statusLabel={ batch.isBatchMode ? (batch.pieceCount > 0 ? `Pieza ${activeBatchPieceIndex + 1} de ${batch.pieceCount}` : 'Sin piezas') @@ -738,6 +771,7 @@ export const ProductionForm: React.FC = ({ brandVisibility={{ logo: false, frame: false, background: true }} outputFormat={template.format} aspectRatio={template.aspectRatio} + brandId={brand.id} /> {/* ═══ Batch Export Modal (video batch only) ═══ */} diff --git a/src/components/export/ExportJobItem.tsx b/src/components/export/ExportJobItem.tsx index f8ddca5..22af235 100644 --- a/src/components/export/ExportJobItem.tsx +++ b/src/components/export/ExportJobItem.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Download, Loader2, CheckCircle2, XCircle, Clock, Trash2, X } from 'lucide-react'; +import { Download, Loader2, CheckCircle2, XCircle, Clock, Trash2, X, FolderOpen } from 'lucide-react'; import type { RenderJobClient } from '../../hooks/useExportQueue'; interface ExportJobItemProps { @@ -49,10 +49,10 @@ export const ExportJobItem: React.FC = ({ job, onCancel, onD {job.status === 'done' && ( )} {(job.status === 'queued' || job.status === 'rendering') && ( diff --git a/src/components/export/ExportModal.tsx b/src/components/export/ExportModal.tsx index cf5474e..a50b5a8 100644 --- a/src/components/export/ExportModal.tsx +++ b/src/components/export/ExportModal.tsx @@ -16,6 +16,7 @@ interface ExportModalProps { brandVisibility?: { logo: boolean; frame: boolean; background: boolean }; outputFormat?: 'video' | 'image'; aspectRatio?: string; + brandId?: string; onAssetSaved?: (url: string) => void; } @@ -54,6 +55,7 @@ export const ExportModal: React.FC = ({ brandVisibility, outputFormat, aspectRatio, + brandId, onAssetSaved, }) => { const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue(); @@ -162,6 +164,7 @@ export const ExportModal: React.FC = ({ layers, brandVisibility, outputFormat, + brandId, }; const job = await startExport(config, { diff --git a/src/components/export/GlobalExportWidget.tsx b/src/components/export/GlobalExportWidget.tsx index 656375c..8b8419d 100644 --- a/src/components/export/GlobalExportWidget.tsx +++ b/src/components/export/GlobalExportWidget.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useExportQueue } from '../../context/ExportQueueContext'; -import { Loader2, Download, Film, Image as ImageIcon, X, Zap } from 'lucide-react'; +import { Loader2, Download, Film, Image as ImageIcon, X, Zap, FolderOpen } from 'lucide-react'; export const GlobalExportWidget: React.FC = () => { const { jobs, activeJobs, hasActiveJobs, downloadJob, cancelJob } = useExportQueue(); @@ -77,7 +77,7 @@ export const GlobalExportWidget: React.FC = () => { onClick={() => downloadJob(job)} className="flex items-center gap-1 text-[10px] bg-violet-600 hover:bg-violet-500 text-white px-2 py-1 rounded transition-colors" > - Descargar + Abrir
)} diff --git a/src/components/express/builder/BuilderCanvas.tsx b/src/components/express/builder/BuilderCanvas.tsx index 80dda68..ca5684f 100644 --- a/src/components/express/builder/BuilderCanvas.tsx +++ b/src/components/express/builder/BuilderCanvas.tsx @@ -85,6 +85,8 @@ export const BuilderCanvas: React.FC = () => { activeScene, updateSegment, previewBrand, + activeVariationId, + resolveFieldPosition, } = useTemplateBuilder(); // Detect segment mode: active scene is an intro/outro with segmentSource @@ -106,13 +108,15 @@ export const BuilderCanvas: React.FC = () => { 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]), + const pos = resolveFieldPosition(field); + updateField(id, { position: { ...pos, x, y } }); + }, [fields, updateField, resolveFieldPosition]), 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]), + const pos = resolveFieldPosition(field); + updateField(id, { position: { ...pos, w, h } }); + }, [fields, updateField, resolveFieldPosition]), snapLines: [50], snapThreshold: 1.5, }); @@ -208,17 +212,18 @@ export const BuilderCanvas: React.FC = () => { const isDraggingField = dragFieldId === field.id; const isLocked = field.locked === true; const colors = NATURE_COLORS[field.nature]; + const pos = resolveFieldPosition(field); return (
{ e.stopPropagation(); if (isLocked) return; // Can't interact with locked layers setSelectedFieldId(field.id); - startDrag(e, field.id, field.position); + startDrag(e, field.id, pos); }} > {/* ── Nature-specific content ── */} diff --git a/src/components/express/builder/FieldConfigPanel.tsx b/src/components/express/builder/FieldConfigPanel.tsx index 2f80a0d..322eccd 100644 --- a/src/components/express/builder/FieldConfigPanel.tsx +++ b/src/components/express/builder/FieldConfigPanel.tsx @@ -67,6 +67,7 @@ export const FieldConfigPanel: React.FC = () => { editableSlotCount, totalFieldCount, templateMeta, + resolveFieldPosition, } = useTemplateBuilder(); const field = fields.find(f => f.id === selectedFieldId); @@ -368,10 +369,10 @@ export const FieldConfigPanel: React.FC = () => { {/* ── Position (FieldInspector) ── */} { updateField(field.id, { - position: { ...field.position, ...pos }, + position: { ...resolveFieldPosition(field), ...pos }, }); }} textStyle={field.type === 'text' ? { diff --git a/src/components/express/builder/TemplateBuilder.tsx b/src/components/express/builder/TemplateBuilder.tsx index aad7f32..52ac226 100644 --- a/src/components/express/builder/TemplateBuilder.tsx +++ b/src/components/express/builder/TemplateBuilder.tsx @@ -169,6 +169,11 @@ const TemplateBuilderInner: React.FC = ({ updateSegment, introScene, outroScene, + // Variations + activeVariationId, + setActiveVariationId, + addVariation, + deleteVariation, } = useTemplateBuilder(); const sceneFieldsMap = useSceneFieldsMap(); @@ -339,6 +344,44 @@ const TemplateBuilderInner: React.FC = ({
+ {/* Variation Selector (design mode only) */} + {viewMode === 'design' && ( +
+ + + {activeVariationId && ( + + )} +
+ )} + {/* Brand preview selector */}
diff --git a/src/components/shared/LivePreviewCanvas.tsx b/src/components/shared/LivePreviewCanvas.tsx index 9b7d418..4d02df4 100644 --- a/src/components/shared/LivePreviewCanvas.tsx +++ b/src/components/shared/LivePreviewCanvas.tsx @@ -32,6 +32,8 @@ export interface LivePreviewCanvasProps { onSceneChange?: (sceneId: string) => void; /** External player ref */ playerRef?: React.RefObject; + /** Optional variation ID to apply */ + variationId?: string; /** Status label (e.g. "Listo" / "Faltan campos") */ statusLabel?: string; /** Whether all required fields are complete */ @@ -69,6 +71,7 @@ export const LivePreviewCanvas: React.FC = ({ statusLabel, isComplete = false, videoDurations, + variationId, }) => { const internalRef = useRef(null); const playerRef = externalRef || internalRef; @@ -85,7 +88,7 @@ export const LivePreviewCanvas: React.FC = ({ // Compile template to timeline (reactive to fieldData + mediaFits) const compiled = useMemo(() => { - const result = compileExpressToTimeline(template, fieldData, designMD, brand, videoDurations); + const result = compileExpressToTimeline(template, fieldData, designMD, brand, videoDurations, variationId); // Strip transitions and apply mediaFit overrides result.elements = result.elements.map(el => { const fieldId = el.sourceFieldId; @@ -100,7 +103,7 @@ export const LivePreviewCanvas: React.FC = ({ }; }); return result; - }, [template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations]); + }, [template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations, variationId]); const playerInputProps = useMemo(() => ({ designMD, diff --git a/src/components/ui/RenderHistoryPanel.tsx b/src/components/ui/RenderHistoryPanel.tsx index 5e54e68..63a7386 100644 --- a/src/components/ui/RenderHistoryPanel.tsx +++ b/src/components/ui/RenderHistoryPanel.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Download, Loader2, X, Clock, CheckCircle, AlertCircle, FileVideo, Image as ImageIcon } from 'lucide-react'; +import { Download, Loader2, X, Clock, CheckCircle, AlertCircle, FileVideo, Image as ImageIcon, FolderOpen } from 'lucide-react'; interface RenderJob { id: string; @@ -9,6 +9,7 @@ interface RenderJob { width: number; height: number; downloadUrl?: string; + targetPath?: string; error?: string; createdAt: number; completedAt?: number; @@ -18,13 +19,14 @@ interface RenderJob { interface RenderHistoryPanelProps { isOpen: boolean; onClose: () => void; + onDownload?: (job: RenderJob) => void; } /** * RenderHistoryPanel — Shows past and active render jobs with progress, * download links, and job status information. */ -export const RenderHistoryPanel: React.FC = ({ isOpen, onClose }) => { +export const RenderHistoryPanel: React.FC = ({ isOpen, onClose, onDownload = () => {} }) => { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(false); @@ -160,15 +162,23 @@ export const RenderHistoryPanel: React.FC = ({ isOpen, {/* Download button */} {job.status === 'done' && job.downloadUrl && ( - { + if ((window as any).electronAPI && job.targetPath) { + (window as any).electronAPI.fs.showItemInFolder(job.targetPath); + } else { + const a = document.createElement('a'); + a.href = job.downloadUrl!; + a.download = `export-${job.id.slice(0, 8)}`; + a.click(); + } + }} + title="Abrir en carpeta" + className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 transition-colors flex items-center gap-1.5" > - - Descargar - + + Abrir en carpeta + )}
diff --git a/src/context/ExportQueueContext.tsx b/src/context/ExportQueueContext.tsx index cf252da..0532986 100644 --- a/src/context/ExportQueueContext.tsx +++ b/src/context/ExportQueueContext.tsx @@ -27,6 +27,8 @@ export interface RenderJobClient { fps: number; durationInFrames: number; compositionId: string; + brandId?: string; + targetPath?: string; downloadUrl?: string; error?: string; createdAt: number; @@ -48,6 +50,7 @@ export interface ExportConfig { layers: TimelineLayer[]; brandVisibility?: { logo: boolean; frame: boolean; background: boolean }; outputFormat?: 'video' | 'image'; + brandId?: string; } interface ExportCallbacks { @@ -101,16 +104,18 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c const oldJob = idx >= 0 ? prev[idx] : null; // Check if it just finished - if (updatedJob.status === 'done' && oldJob?.status !== 'done' && updatedJob.downloadUrl) { + if (updatedJob.status === 'done' && oldJob?.status !== 'done') { const cbs = callbacksRef.current[updatedJob.id]; if (cbs?.onSuccess) { - cbs.onSuccess(updatedJob.downloadUrl); + cbs.onSuccess(updatedJob.downloadUrl || updatedJob.targetPath || ''); } delete callbacksRef.current[updatedJob.id]; - - if (!cbs?.onSuccess) { - // If there's no custom callback, show a generic toast - showToast('Renderización completada', 'success'); + showToast('Renderización completada con éxito', 'success'); + + if (window.electronAPI && updatedJob.brandId && updatedJob.targetPath) { + const type = updatedJob.format === 'mp4' || updatedJob.format === 'webm' ? 'video' : 'image'; + window.electronAPI.fs.registerGeneratedMedia(updatedJob.brandId, type, updatedJob.targetPath) + .catch((err: any) => console.warn('Failed to register media:', err)); } } else if (updatedJob.status === 'error' && oldJob?.status !== 'error') { const cbs = callbacksRef.current[updatedJob.id]; @@ -157,8 +162,13 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c .catch(() => {}); return () => { - eventSourceRef.current?.close(); - if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } }; }, [connect]); @@ -213,6 +223,13 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c outputFormat: config.outputFormat, }; + let targetPath: string | undefined; + if (window.electronAPI && config.brandId) { + const type = config.format === 'mp4' || config.format === 'webm' ? 'video' : 'image'; + const ext = config.format === 'jpeg' ? 'jpg' : config.format; + targetPath = await window.electronAPI.fs.getNextFilename(config.brandId, type, ext); + } + const body = { format: config.format, width: config.width, @@ -220,6 +237,8 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c fps: config.fps, durationInFrames: isStill ? 1 : config.durationInFrames, compositionId: isStill ? 'BrandStill' : 'BrandVideo', + brandId: config.brandId, + targetPath, inputProps, }; @@ -265,22 +284,16 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c const downloadJob = useCallback(async (job: RenderJobClient) => { if (!job.downloadUrl) return; - const defaultName = `export-${job.id.slice(0, 8)}.${job.format}`; - - // In Electron, use native save dialog via IPC const electronAPI = (window as any).electronAPI; - if (electronAPI?.saveRenderedFile) { - try { - const savedPath = await electronAPI.saveRenderedFile(job.downloadUrl, defaultName); - if (savedPath) { - console.log('✅ Saved to:', savedPath); - } - } catch (err) { - console.error('Save failed:', err); - } + + // In Electron, if it has a targetPath, just open the folder + if (electronAPI?.fs?.showItemInFolder && job.targetPath) { + await electronAPI.fs.showItemInFolder(job.targetPath); return; } + const defaultName = `export-${job.id.slice(0, 8)}.${job.format}`; + // Web fallback: tag download const a = document.createElement('a'); a.href = job.downloadUrl; diff --git a/src/context/TemplateBuilderContext.tsx b/src/context/TemplateBuilderContext.tsx index b2e779a..0fea900 100644 --- a/src/context/TemplateBuilderContext.tsx +++ b/src/context/TemplateBuilderContext.tsx @@ -128,6 +128,15 @@ export interface TemplateBuilderState { introScene: ExpressScene | null; /** The outro scene (first scene with type 'outro'), or null */ outroScene: ExpressScene | null; + + // ── Variations management ── + activeVariationId: string | null; + setActiveVariationId: (id: string | null) => void; + addVariation: (name: string) => void; + deleteVariation: (id: string) => void; + updateVariationName: (id: string, name: string) => void; + /** Resolves a field's position, applying the active variation if set */ + resolveFieldPosition: (field: TemplateField) => TemplateField['position']; } const TemplateBuilderContext = createContext(null); @@ -234,6 +243,14 @@ export const TemplateBuilderProvider: React.FC = ( const [activeSceneId, setActiveSceneId] = useState(initialScenes[0]?.id || null); const activeScene = scenes.find(s => s.id === activeSceneId) || null; + // ── Variations ── + const [activeVariationId, setActiveVariationId] = useState(null); + + const updateActiveScene = useCallback((updates: Partial) => { + if (!activeSceneId) return; + setScenes(prev => prev.map(s => s.id === activeSceneId ? { ...s, ...updates } : s)); + }, [activeSceneId]); + // ── Per-scene fields map ── const [sceneFieldsMap, setSceneFieldsMap] = useState>(() => { const map: Record = {}; @@ -299,10 +316,41 @@ export const TemplateBuilderProvider: React.FC = ( const updateFieldCb = useCallback((id: string, updates: Partial) => { if (!activeSceneId) return; + + // If there's an active variation, intercept POSITION updates + if (activeVariationId && updates.position) { + setScenes(prev => prev.map(s => { + if (s.id !== activeSceneId) return s; + const vIdx = s.variations?.findIndex(v => v.id === activeVariationId); + if (vIdx === undefined || vIdx === -1) return s; + + const nextVariations = [...(s.variations || [])]; + const nextPositions = { ...nextVariations[vIdx].positions }; + + // Merge existing variation position with incoming updates + // If the variation didn't have a position for this field yet, we merge the incoming with the base field's position + // Wait, to get the base position we need to look it up, but `updates.position` usually contains all {x, y, w, h} from useDragResize. + // We'll just store the incoming position update completely. + nextPositions[id] = { + ...nextPositions[id], + ...updates.position, + } as TemplateField['position']; + + nextVariations[vIdx] = { ...nextVariations[vIdx], positions: nextPositions }; + return { ...s, variations: nextVariations }; + })); + + // If the updates ONLY contain position, we stop here so it doesn't affect the base field. + const otherUpdates = { ...updates }; + delete otherUpdates.position; + if (Object.keys(otherUpdates).length === 0) return; + updates = otherUpdates; // continue updating base field with non-position updates + } + updateSceneFields(activeSceneId, prev => prev.map(f => f.id === id ? { ...f, ...updates } : f) ); - }, [activeSceneId, updateSceneFields]); + }, [activeSceneId, activeVariationId, updateSceneFields]); const removeField = useCallback((id: string) => { if (!activeSceneId) return; @@ -440,6 +488,35 @@ export const TemplateBuilderProvider: React.FC = ( setScenes(prev => prev.map(s => s.id === sceneId ? { ...s, ...updates } : s)); }, [setScenes]); + // ── Variation CRUD ── + const addVariation = useCallback((name: string) => { + if (!activeSceneId || !activeScene) return; + const newId = `var-${Date.now()}`; + const newVar = { id: newId, name, positions: {} }; + updateActiveScene({ variations: [...(activeScene.variations || []), newVar] }); + setActiveVariationId(newId); + }, [activeSceneId, activeScene, updateActiveScene]); + + const deleteVariation = useCallback((id: string) => { + if (!activeSceneId || !activeScene) return; + const filtered = (activeScene.variations || []).filter(v => v.id !== id); + updateActiveScene({ variations: filtered }); + if (activeVariationId === id) setActiveVariationId(null); + }, [activeSceneId, activeScene, activeVariationId, updateActiveScene]); + + const updateVariationName = useCallback((id: string, name: string) => { + if (!activeSceneId || !activeScene) return; + const mapped = (activeScene.variations || []).map(v => v.id === id ? { ...v, name } : v); + updateActiveScene({ variations: mapped }); + }, [activeSceneId, activeScene, updateActiveScene]); + + const resolveFieldPosition = useCallback((field: TemplateField) => { + if (!activeVariationId || !activeScene?.variations) return field.position; + const variation = activeScene.variations.find(v => v.id === activeVariationId); + if (!variation || !variation.positions[field.id]) return field.position; + return { ...field.position, ...variation.positions[field.id] }; + }, [activeVariationId, activeScene]); + // ── Expose getSceneFieldsMap for save ── // We attach it to the context so TemplateBuilder can access all scene fields at save time const value: TemplateBuilderState & { _sceneFieldsMap: Record } = { @@ -496,6 +573,14 @@ export const TemplateBuilderProvider: React.FC = ( introScene, outroScene, + // Variation management + activeVariationId, + setActiveVariationId, + addVariation, + deleteVariation, + updateVariationName, + resolveFieldPosition, + // Internal: for save access _sceneFieldsMap: sceneFieldsMap, }; diff --git a/src/electron/main.ts b/src/electron/main.ts index 11489d2..0312649 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -6,7 +6,7 @@ * - Starts the embedded Express server * - Manages IPC handlers for native features */ -import { app, BrowserWindow, ipcMain, dialog } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; import path from 'path'; import fs from 'fs'; import net from 'net'; @@ -123,7 +123,7 @@ function createWindow() { backgroundColor: '#0a0a0a', show: false, // Show when ready to prevent visual flash webPreferences: { - preload: path.join(__dirname, '../preload/index.js'), + preload: path.join(__dirname, '../preload', fs.existsSync(path.join(__dirname, '../preload/index.js')) ? 'index.js' : 'index.mjs'), contextIsolation: true, nodeIntegration: false, sandbox: false, // Required for preload to access Node APIs @@ -153,6 +153,43 @@ function createWindow() { }); } +// ═══ Workspace Settings ═══ + +const getSettingsPath = () => path.join(app.getPath('userData'), 'settings.json'); + +function getWorkspacePath(): string { + try { + if (fs.existsSync(getSettingsPath())) { + const settings = JSON.parse(fs.readFileSync(getSettingsPath(), 'utf-8')); + if (settings.workspacePath) { + return settings.workspacePath; + } + } + } catch (e) { + console.error('Failed to read settings', e); + } + return path.join(app.getPath('documents'), 'saas-branding'); +} + +function setWorkspacePath(newPath: string) { + try { + let settings: any = {}; + if (fs.existsSync(getSettingsPath())) { + settings = JSON.parse(fs.readFileSync(getSettingsPath(), 'utf-8')); + } + settings.workspacePath = newPath; + fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2), 'utf-8'); + + // Ensure directory exists + if (!fs.existsSync(newPath)) { + fs.mkdirSync(newPath, { recursive: true }); + } + } catch (e) { + console.error('Failed to save settings', e); + } +} + + // ═══ IPC Handlers ═══ function setupIPC() { @@ -248,6 +285,259 @@ function setupIPC() { return null; } }); + + // ═══ Brand & Templates File System APIs ═══ + + ipcMain.handle('fs:getWorkspacePath', () => { + return getWorkspacePath(); + }); + + ipcMain.handle('fs:setWorkspacePath', async () => { + if (!mainWindow) return null; + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory', 'createDirectory'] + }); + if (!result.canceled && result.filePaths.length > 0) { + const newPath = result.filePaths[0]; + setWorkspacePath(newPath); + return newPath; + } + return null; + }); + + ipcMain.handle('fs:getGeneratedMedia', async (_event, brandId: string, type: 'video' | 'image') => { + try { + const root = getWorkspacePath(); + const metadataPath = path.join(root, brandId, 'brand', type === 'video' ? 'videos.json' : 'images.json'); + if (fs.existsSync(metadataPath)) { + const raw = fs.readFileSync(metadataPath, 'utf-8'); + return JSON.parse(raw); + } + return []; + } catch (e) { + console.error(e); + return []; + } + }); + + ipcMain.handle('fs:getBrands', () => { + const root = getWorkspacePath(); + if (!fs.existsSync(root)) return []; + + const brands: any[] = []; + const entries = fs.readdirSync(root, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== 'Templates') { + const configPath = path.join(root, entry.name, 'brand', 'config.json'); + if (fs.existsSync(configPath)) { + try { + const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + brands.push(data); + } catch (e) { + console.error(`Failed to read brand config at ${configPath}`, e); + } + } + } + } + return brands; + }); + + ipcMain.handle('fs:saveBrand', (_event, brand: any) => { + const root = getWorkspacePath(); + // Use the ID as the slug + const brandDir = path.join(root, brand.id); + + const brandConfigDir = path.join(brandDir, 'brand'); + const imagesDir = path.join(brandDir, 'images'); + const videosDir = path.join(brandDir, 'videos'); + + for (const dir of [brandConfigDir, imagesDir, videosDir]) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } + + // Create tracking files if they don't exist + for (const file of ['images.json', 'videos.json']) { + const filePath = path.join(brandConfigDir, file); + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, JSON.stringify([]), 'utf-8'); + } + } + + const configPath = path.join(brandConfigDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(brand, null, 2), 'utf-8'); + return true; + }); + + ipcMain.handle('fs:deleteBrand', (_event, brandId: string) => { + const root = getWorkspacePath(); + const brandDir = path.join(root, brandId); + if (fs.existsSync(brandDir)) { + fs.rmSync(brandDir, { recursive: true, force: true }); + return true; + } + return false; + }); + + ipcMain.handle('fs:getTemplates', () => { + const templatesDir = path.join(getWorkspacePath(), 'Templates'); + if (!fs.existsSync(templatesDir)) return []; + + const templates: any[] = []; + const entries = fs.readdirSync(templatesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.json')) { + const filePath = path.join(templatesDir, entry.name); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + templates.push(data); + } catch (e) { + console.error(`Failed to read template at ${filePath}`, e); + } + } + } + return templates; + }); + + ipcMain.handle('fs:saveTemplate', (_event, template: any) => { + const templatesDir = path.join(getWorkspacePath(), 'Templates'); + if (!fs.existsSync(templatesDir)) fs.mkdirSync(templatesDir, { recursive: true }); + + const filePath = path.join(templatesDir, `${template.id}.json`); + fs.writeFileSync(filePath, JSON.stringify(template, null, 2), 'utf-8'); + return true; + }); + + ipcMain.handle('fs:deleteTemplate', (_event, templateId: string) => { + const templatesDir = path.join(getWorkspacePath(), 'Templates'); + const filePath = path.join(templatesDir, `${templateId}.json`); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + return false; + }); + + ipcMain.handle('fs:openFolder', async (_event, targetPath: string) => { + try { + if (!fs.existsSync(targetPath)) { + fs.mkdirSync(targetPath, { recursive: true }); + } + await shell.openPath(targetPath); + return true; + } catch (e) { + console.error('Failed to open folder', e); + return false; + } + }); + + ipcMain.handle('fs:showItemInFolder', async (_event, targetPath: string) => { + try { + if (fs.existsSync(targetPath)) { + shell.showItemInFolder(targetPath); + return true; + } + return false; + } catch (e) { + console.error('Failed to show item in folder', e); + return false; + } + }); + + ipcMain.handle('fs:getNextFilename', (_event, brandId: string, type: 'video' | 'image', ext: string) => { + const dir = path.join(getWorkspacePath(), brandId, type === 'video' ? 'videos' : 'images'); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const files = fs.readdirSync(dir); + let maxCounter = 0; + + // Look for files matching {brandId}_XXX.ext + const regex = new RegExp(`^${brandId}_(\\d{3})\\.${ext}$`, 'i'); + for (const file of files) { + const match = file.match(regex); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxCounter) maxCounter = num; + } + } + + const nextCounter = String(maxCounter + 1).padStart(3, '0'); + return path.join(dir, `${brandId}_${nextCounter}.${ext}`); + }); + + ipcMain.handle('fs:registerGeneratedMedia', (_event, brandId: string, type: 'video' | 'image', filePath: string) => { + const brandConfigDir = path.join(getWorkspacePath(), brandId, 'brand'); + const jsonFile = path.join(brandConfigDir, type === 'video' ? 'videos.json' : 'images.json'); + + try { + let items: any[] = []; + if (fs.existsSync(jsonFile)) { + items = JSON.parse(fs.readFileSync(jsonFile, 'utf-8')); + } + + const exists = items.some(i => i.path === filePath); + if (!exists) { + items.push({ + path: filePath, + date: new Date().toISOString(), + name: '' + }); + fs.writeFileSync(jsonFile, JSON.stringify(items, null, 2), 'utf-8'); + } + + return true; + } catch (e) { + console.error('Failed to register media', e); + return false; + } + }); + + ipcMain.handle('fs:renameGeneratedMedia', (_event, brandId: string, type: 'video' | 'image', filePath: string, newName: string) => { + try { + if (!fs.existsSync(filePath)) return false; + + // Update the metadata JSON + const brandConfigDir = path.join(getWorkspacePath(), brandId, 'brand'); + const jsonFile = path.join(brandConfigDir, type === 'video' ? 'videos.json' : 'images.json'); + if (fs.existsSync(jsonFile)) { + const items = JSON.parse(fs.readFileSync(jsonFile, 'utf-8')); + const updated = items.map((item: any) => + item.path === filePath ? { ...item, name: newName } : item + ); + fs.writeFileSync(jsonFile, JSON.stringify(updated, null, 2), 'utf-8'); + } + return filePath; + } catch (e) { + console.error('Failed to update media name', e); + return false; + } + }); + + ipcMain.handle('fs:getContentMesh', () => { + try { + const file = path.join(getWorkspacePath(), 'content.json'); + if (fs.existsSync(file)) { + return JSON.parse(fs.readFileSync(file, 'utf-8')); + } + return {}; + } catch (e) { + console.error('Failed to read content mesh', e); + return {}; + } + }); + + ipcMain.handle('fs:saveContentMesh', (_event, data: any) => { + try { + const file = path.join(getWorkspacePath(), 'content.json'); + fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8'); + return true; + } catch (e) { + console.error('Failed to save content mesh', e); + return false; + } + }); + } diff --git a/src/electron/preload.ts b/src/electron/preload.ts index a6751cf..0fc40f3 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -52,6 +52,26 @@ const electronAPI = { userData: string; isPackaged: boolean; }>, + + // ─── File System (fs) ─── + fs: { + getWorkspacePath: () => ipcRenderer.invoke('fs:getWorkspacePath') as Promise, + setWorkspacePath: () => ipcRenderer.invoke('fs:setWorkspacePath') as Promise, + getBrands: () => ipcRenderer.invoke('fs:getBrands') as Promise, + saveBrand: (brand: any) => ipcRenderer.invoke('fs:saveBrand', brand) as Promise, + deleteBrand: (brandId: string) => ipcRenderer.invoke('fs:deleteBrand', brandId) as Promise, + getTemplates: () => ipcRenderer.invoke('fs:getTemplates') as Promise, + saveTemplate: (template: any) => ipcRenderer.invoke('fs:saveTemplate', template) as Promise, + deleteTemplate: (templateId: string) => ipcRenderer.invoke('fs:deleteTemplate', templateId) as Promise, + openFolder: (path: string) => ipcRenderer.invoke('fs:openFolder', path) as Promise, + showItemInFolder: (path: string) => ipcRenderer.invoke('fs:showItemInFolder', path) as Promise, + getNextFilename: (brandId: string, type: 'video' | 'image', ext: string) => ipcRenderer.invoke('fs:getNextFilename', brandId, type, ext) as Promise, + registerGeneratedMedia: (brandId: string, type: 'video' | 'image', filePath: string) => ipcRenderer.invoke('fs:registerGeneratedMedia', brandId, type, filePath) as Promise, + getGeneratedMedia: (brandId: string, type: 'video' | 'image') => ipcRenderer.invoke('fs:getGeneratedMedia', brandId, type) as Promise, + renameGeneratedMedia: (brandId: string, type: 'video' | 'image', oldPath: string, newName: string) => ipcRenderer.invoke('fs:renameGeneratedMedia', brandId, type, oldPath, newName) as Promise, + getContentMesh: () => ipcRenderer.invoke('fs:getContentMesh') as Promise, + saveContentMesh: (data: any) => ipcRenderer.invoke('fs:saveContentMesh', data) as Promise, + } }; // Expose to the renderer process diff --git a/src/hooks/useBatchProduction.ts b/src/hooks/useBatchProduction.ts index cb9277e..9a79d12 100644 --- a/src/hooks/useBatchProduction.ts +++ b/src/hooks/useBatchProduction.ts @@ -27,6 +27,7 @@ export interface UseBatchProductionResult { invalidCount: number; setBackgroundFiles: (files: File[]) => void; updatePieceField: (index: number, fieldId: string, value: string) => void; + updatePieceVariation: (index: number, variationId: string | null) => void; importCSV: (file: File) => Promise<{ matched: number; unmatched: number }>; removePiece: (index: number) => void; clearBatch: () => void; @@ -86,6 +87,14 @@ export function useBatchProduction( })); }, []); + // ─── Update a single piece variation ─── + const updatePieceVariation = useCallback((index: number, variationId: string | null) => { + setPieces(prev => prev.map((p, i) => { + if (i !== index) return p; + return { ...p, variationId: variationId || undefined }; + })); + }, []); + // ─── Validate all pieces ─── const validateAll = useCallback((): boolean => { let allOk = true; @@ -243,6 +252,7 @@ export function useBatchProduction( invalidCount, setBackgroundFiles, updatePieceField, + updatePieceVariation, importCSV, removePiece, clearBatch, diff --git a/src/hooks/usePersistence.ts b/src/hooks/usePersistence.ts deleted file mode 100644 index b9fb1d7..0000000 --- a/src/hooks/usePersistence.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { CompanyProfile, ExpressTemplate } from '../types'; - -const STORAGE_KEY = 'remix-designmd-companies'; -const TEMPLATES_STORAGE_KEY = 'remix-global-templates'; - -/** - * Load companies from localStorage. Returns null if nothing saved. - */ -export function loadCompanies(): CompanyProfile[] | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - return JSON.parse(raw) as CompanyProfile[]; - } catch { - return null; - } -} - -/** - * Save companies to localStorage. - */ -export function saveCompanies(companies: CompanyProfile[]): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(companies)); - } catch (e) { - console.warn('Failed to save to localStorage:', e); - } -} - -/** - * Hook that auto-saves companies to localStorage whenever they change. - * Uses debouncing (500ms) to avoid excessive writes. - */ -export function usePersistence(companies: CompanyProfile[]): void { - const timerRef = useRef | null>(null); - const isInitialMount = useRef(true); - - useEffect(() => { - // Skip the initial mount to avoid overwriting saved data with defaults - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - saveCompanies(companies); - }, 500); - - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, [companies]); -} - -// ═══ Template Persistence ═══ - -/** - * Load custom templates from localStorage. Returns null if nothing saved. - */ -export function loadTemplates(): ExpressTemplate[] | null { - try { - const raw = localStorage.getItem(TEMPLATES_STORAGE_KEY); - if (!raw) return null; - return JSON.parse(raw) as ExpressTemplate[]; - } catch { - return null; - } -} - -/** - * Save custom templates to localStorage. - */ -export function saveTemplates(templates: ExpressTemplate[]): void { - try { - localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(templates)); - } catch (e) { - console.warn('Failed to save templates to localStorage:', e); - } -} - -/** - * Hook that auto-saves templates to localStorage whenever they change. - * Uses debouncing (500ms) to avoid excessive writes. - */ -export function useTemplatePersistence(templates: ExpressTemplate[]): void { - const timerRef = useRef | null>(null); - const isInitialMount = useRef(true); - - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - saveTemplates(templates); - }, 500); - - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, [templates]); -} - -/** - * Clear all persisted data. - */ -export function clearStorage(): void { - localStorage.removeItem(STORAGE_KEY); - localStorage.removeItem(TEMPLATES_STORAGE_KEY); -} diff --git a/src/server/renderQueue.ts b/src/server/renderQueue.ts index 1196266..6fba6ea 100644 --- a/src/server/renderQueue.ts +++ b/src/server/renderQueue.ts @@ -29,7 +29,9 @@ export interface RenderJob { fps: number; durationInFrames: number; compositionId: string; + brandId?: string; inputProps: Record; + targetPath?: string; outputPath?: string; downloadUrl?: string; error?: string; @@ -51,7 +53,9 @@ export interface RenderJobCreateParams { fps: number; durationInFrames: number; compositionId: string; + brandId?: string; inputProps: Record; + targetPath?: string; } // ═══ Constants ═══ @@ -190,7 +194,13 @@ async function renderJob(job: RenderJob): Promise { const serveUrl = process.env.BRADLY_SERVE_URL || DEFAULT_SERVE_URL; const isStill = job.format === 'png' || job.format === 'jpeg'; const ext = job.format; - const outputPath = path.join(RENDERS_DIR, `${job.id}.${ext}`); + const outputPath = job.targetPath || path.join(RENDERS_DIR, `${job.id}.${ext}`); + + // Ensure the directory for the target path exists + const targetDir = path.dirname(outputPath); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } console.log(`🎬 Rendering [${job.id}] → ${job.format} (${job.width}×${job.height})`); diff --git a/src/types.ts b/src/types.ts index de18cd1..6fb4728 100644 --- a/src/types.ts +++ b/src/types.ts @@ -369,6 +369,8 @@ export interface BatchPieceData { isValid: boolean; /** Validation errors per field */ errors: Record; + /** Optional variation ID */ + variationId?: string; } // ═══ Express Editor Types ═══ @@ -521,6 +523,14 @@ export interface ExpressField { }; } +/** Design Variation — stores position overrides for fields */ +export interface TemplateVariation { + id: string; + name: string; + /** Map of field.id -> Position override */ + positions: Record; +} + /** A single scene block in the storyboard */ export interface ExpressScene { id: string; @@ -532,6 +542,8 @@ export interface ExpressScene { editableFields: ExpressField[]; /** New schema-based fields (used by redesigned builder) */ fields?: TemplateField[]; + /** Template design variations (allow repositioning fields) */ + variations?: TemplateVariation[]; /** Scene-level transition animation */ transition?: { type: string; duration: number }; /** Background: solid color, gradient, or media (user-replaceable) */ diff --git a/src/utils/batchExporter.ts b/src/utils/batchExporter.ts index 2c73fac..0c0554a 100644 --- a/src/utils/batchExporter.ts +++ b/src/utils/batchExporter.ts @@ -58,7 +58,8 @@ async function renderPieceToImage( backgroundFieldId: string | null, dimensions: { w: number; h: number }, options: BatchExportOptions, -): Promise { + targetPath?: string, +): Promise { // Build fieldData with background injected const rawFieldData: Record = { ...piece.fieldData }; if (backgroundFieldId && piece.backgroundUrl) { @@ -68,7 +69,7 @@ async function renderPieceToImage( // Resolve blob: URLs to persistent server URLs const fieldData = await resolveBlobFieldData(rawFieldData); - const compiled = compileExpressToTimeline(template, fieldData, designMD, brand); + const compiled = compileExpressToTimeline(template, fieldData, designMD, brand, undefined, piece.variationId); // Strip transitions compiled.elements = compiled.elements.map(el => ({ ...el, @@ -96,6 +97,7 @@ async function renderPieceToImage( durationInFrames: 1, compositionId: 'BrandStill', inputProps, + targetPath, }; const res = await fetch('/api/render/start', { @@ -122,10 +124,8 @@ async function renderPieceToImage( if (!statusRes.ok) continue; const statusData = await statusRes.json(); - if (statusData.status === 'done' && statusData.downloadUrl) { - const fileRes = await fetch(statusData.downloadUrl); - if (!fileRes.ok) throw new Error(`Download failed for piece ${piece.index + 1}`); - return await fileRes.blob(); + if (statusData.status === 'done') { + return; // Finished rendering to targetPath! } if (statusData.status === 'error') { @@ -137,16 +137,15 @@ async function renderPieceToImage( } /** - * Export all batch pieces as a ZIP file. + * Export all batch pieces directly to the brand's local folder. * * @param pieces - Array of batch pieces to render * @param template - The Express template - * @param brand - Brand profile (for DesignMD + brand variables) + * @param brand - Brand profile * @param options - Export format options * @param onProgress - Progress callback - * @returns Promise that resolves when download starts */ -export async function exportBatchAsZip( +export async function exportBatchToDisk( pieces: BatchPieceData[], template: ExpressTemplate, brand: CompanyProfile, @@ -156,10 +155,10 @@ export async function exportBatchAsZip( const designMD = brand.design; const dimensions = getAspectDimensions(template.aspectRatio); const backgroundFieldId = findBackgroundFieldId(template); - const zip = new JSZip(); const validPieces = pieces.filter(p => p.isValid); const total = validPieces.length; + const ext = options.format === 'jpeg' ? 'jpg' : 'png'; onProgress?.({ current: 0, total, status: 'rendering' }); @@ -167,50 +166,27 @@ export async function exportBatchAsZip( const piece = validPieces[i]; try { - const blob = await renderPieceToImage( - piece, template, designMD, brand, backgroundFieldId, dimensions, options, + let targetPath: string | undefined; + const electronAPI = (typeof window !== 'undefined') ? (window as any).electronAPI : null; + + if (electronAPI?.fs) { + targetPath = await electronAPI.fs.getNextFilename(brand.id, 'image', ext); + } + + await renderPieceToImage( + piece, template, designMD, brand, backgroundFieldId, dimensions, options, targetPath ); - // Name file: use background filename (without ext) or fallback to index - const ext = options.format === 'jpeg' ? 'jpg' : 'png'; - const baseName = piece.backgroundFilename - ? piece.backgroundFilename.replace(/\.[^.]+$/, '') - : `pieza-${piece.index + 1}`; - const fileName = `${baseName}.${ext}`; - - zip.file(fileName, blob); + // Register the file in the brand's JSON + if (electronAPI?.fs && targetPath) { + await electronAPI.fs.registerGeneratedMedia(brand.id, 'image', targetPath); + } } catch (err) { console.error(`Failed to render piece ${piece.index + 1}:`, err); - // Add an error placeholder - zip.file(`ERROR_pieza-${piece.index + 1}.txt`, `Error rendering piece: ${err}`); } onProgress?.({ current: i + 1, total, status: 'rendering' }); } - onProgress?.({ current: total, total, status: 'packaging' }); - - // Generate ZIP - const zipBlob = await zip.generateAsync({ type: 'blob' }); - - // Trigger download - const zipName = `${template.name}_${brand.name}_lote-${total}.zip` - .replace(/\s+/g, '_') - .replace(/[^a-zA-Z0-9._-]/g, ''); - - // In Electron, use native save dialog - const electronAPI = (typeof window !== 'undefined') ? (window as any).electronAPI : null; - if (electronAPI?.saveBlobFile) { - const arrayBuffer = await zipBlob.arrayBuffer(); - await electronAPI.saveBlobFile( - new Uint8Array(arrayBuffer), - zipName, - [{ name: 'ZIP Archive', extensions: ['zip'] }], - ); - } else { - // Web fallback - saveAs(zipBlob, zipName); - } - onProgress?.({ current: total, total, status: 'done' }); } diff --git a/src/utils/expressCompiler.ts b/src/utils/expressCompiler.ts index b3649e3..99a114c 100644 --- a/src/utils/expressCompiler.ts +++ b/src/utils/expressCompiler.ts @@ -122,6 +122,7 @@ export function compileExpressToTimeline( designMD: DesignMD, company?: CompanyProfile, videoDurations?: Record, + variationId?: string, ): { elements: TimelineElement[]; layers: TimelineLayer[] } { const fps = 30; const elements: TimelineElement[] = []; @@ -278,14 +279,19 @@ export function compileExpressToTimeline( }); } - // Process fields — prefer new TemplateField[] format over legacy editableFields const fieldsToProcess = (scene.fields && scene.fields.length > 0) ? scene.fields : null; + const activeVariation = variationId && scene.variations ? scene.variations.find(v => v.id === variationId) : null; + if (fieldsToProcess) { // New TemplateField[] format: process ALL natures for (const field of fieldsToProcess) { + const position = activeVariation && activeVariation.positions[field.id] + ? { ...field.position, ...activeVariation.positions[field.id] } + : field.position; + let value: string; if (field.nature === 'static') { @@ -346,12 +352,12 @@ export function compileExpressToTimeline( sourceFieldId: field.id, type: elType, content: field.type === 'sticker' ? compiledContent : (value || ''), - x: field.position.x, - y: field.position.y, + x: position.x, + y: position.y, startFrame: sceneStart, endFrame: sceneEnd, scale: 1, - rotation: field.position.rotation || 0, + rotation: position.rotation || 0, opacity: field.style.opacity ?? 100, blendMode: field.style.blendMode, layerId, @@ -372,8 +378,8 @@ export function compileExpressToTimeline( textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || (field.type === 'sticker' ? 'left' : 'center'), } : {}), ...(field.type === 'image' || field.type === 'video' ? { - width: field.position.w, - height: field.position.h, + width: position.w, + height: position.h, objectFit: ((field.nature === 'brand-variable' && field.brandSource === 'intro-video') ? (designMD.introVideoFit || field.style.mediaFit || 'cover') : (field.nature === 'brand-variable' && field.brandSource === 'outro-video') @@ -386,8 +392,8 @@ export function compileExpressToTimeline( : undefined, } : {}), ...(field.type === 'shape' ? { - width: field.position.w, - height: field.position.h, + width: position.w, + height: position.h, shapeType: field.style.shapeType || 'rectangle', color: field.style.shapeFill || designMD.primaryColor, } : {}), @@ -397,6 +403,10 @@ export function compileExpressToTimeline( } else { // Legacy ExpressField[] format for (const field of scene.editableFields) { + const position = activeVariation && activeVariation.positions[field.id] + ? { ...field.position, ...activeVariation.positions[field.id] } + : field.position; + let value = resolveBrandValue(field, fieldData[field.id] || '', designMD, company, company?.brandContent); // For media fields, placeholder text is not a valid URL — clear it to avoid crashing Remotion const isLegacyMedia = field.type === 'media' || field.type === 'logo'; @@ -421,8 +431,8 @@ export function compileExpressToTimeline( sourceFieldId: field.id, type: elType, content: value || '', - x: field.position.x, - y: field.position.y, + x: position.x, + y: position.y, startFrame: sceneStart, endFrame: sceneEnd, scale: 1, @@ -444,11 +454,11 @@ export function compileExpressToTimeline( fontFamily: resolveFont(field, designMD), color: resolveColor(field, designMD), textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || 'center', - width: field.position.w, + width: position.w, } : {}), ...(field.type === 'media' || field.type === 'logo' ? { - width: field.position.w, - height: field.position.h, + width: position.w, + height: position.h, objectFit: 'cover' as const, } : {}), transitionIn: scene.transition ? { type: scene.transition.type as TransitionType, duration: scene.transition.duration } : undefined,