feat: daily timeline advanced UI and calendar integration
This commit is contained in:
@@ -54,6 +54,59 @@ export async function createExpressApp() {
|
|||||||
// Add JSON parser with generous limit for render payloads (timelineElements can be large)
|
// Add JSON parser with generous limit for render payloads (timelineElements can be large)
|
||||||
app.use(express.json({ limit: '50mb' }));
|
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 ═══
|
// ═══ Serve uploaded media files ═══
|
||||||
app.use("/api/media", express.static(UPLOADS_DIR, {
|
app.use("/api/media", express.static(UPLOADS_DIR, {
|
||||||
maxAge: "1d",
|
maxAge: "1d",
|
||||||
@@ -212,7 +265,7 @@ export async function createExpressApp() {
|
|||||||
// Start a render job
|
// Start a render job
|
||||||
app.post("/api/render/start", async (req, res) => {
|
app.post("/api/render/start", async (req, res) => {
|
||||||
try {
|
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) {
|
if (!format || !width || !height || !compositionId) {
|
||||||
return res.status(400).json({ error: "Missing required fields: 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,
|
durationInFrames: durationInFrames || 150,
|
||||||
compositionId,
|
compositionId,
|
||||||
inputProps: inputProps || {},
|
inputProps: inputProps || {},
|
||||||
|
targetPath,
|
||||||
|
brandId
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`🎬 Job created: ${job.id} (${format} ${width}x${height})`);
|
console.log(`🎬 Job created: ${job.id} (${format} ${width}x${height})`);
|
||||||
|
|||||||
+82
-45
@@ -11,7 +11,7 @@ import { EditorProvider } from './context/EditorContext';
|
|||||||
import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults';
|
import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults';
|
||||||
import { useCustomTooltips } from './hooks/useCustomTooltips';
|
import { useCustomTooltips } from './hooks/useCustomTooltips';
|
||||||
import { useToast } from './components/ui/ToastProvider';
|
import { useToast } from './components/ui/ToastProvider';
|
||||||
import { usePersistence, loadCompanies, useTemplatePersistence, loadTemplates } from './hooks/usePersistence';
|
|
||||||
import { ContentGridView } from './components/content-grid/ContentGridView';
|
import { ContentGridView } from './components/content-grid/ContentGridView';
|
||||||
import { TemplateBuilder } from './components/express/builder/TemplateBuilder';
|
import { TemplateBuilder } from './components/express/builder/TemplateBuilder';
|
||||||
import { EXPRESS_TEMPLATES } from './config/expressTemplates';
|
import { EXPRESS_TEMPLATES } from './config/expressTemplates';
|
||||||
@@ -36,9 +36,7 @@ function saveContentData(data: Record<string, { pieces: ContentPiece[]; pillars:
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [companies, setCompanies] = useState<CompanyProfile[]>(() => {
|
const [companies, setCompanies] = useState<CompanyProfile[]>(PREDEFINED_COMPANIES);
|
||||||
return loadCompanies() ?? PREDEFINED_COMPANIES;
|
|
||||||
});
|
|
||||||
const [currentCompanyId, setCurrentCompanyId] = useState<string | null>(null);
|
const [currentCompanyId, setCurrentCompanyId] = useState<string | null>(null);
|
||||||
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
|
const [currentProjectId, setCurrentProjectId] = useState<string | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState<Step>('dashboard');
|
const [currentStep, setCurrentStep] = useState<Step>('dashboard');
|
||||||
@@ -47,9 +45,7 @@ export default function App() {
|
|||||||
const [editingBrandAsset, setEditingBrandAsset] = useState<{ type: keyof DesignMD; url: string } | null>(null);
|
const [editingBrandAsset, setEditingBrandAsset] = useState<{ type: keyof DesignMD; url: string } | null>(null);
|
||||||
|
|
||||||
// Global templates (decoupled from brands) — persisted
|
// Global templates (decoupled from brands) — persisted
|
||||||
const [globalTemplates, setGlobalTemplates] = useState<ExpressTemplate[]>(() => {
|
const [globalTemplates, setGlobalTemplates] = useState<ExpressTemplate[]>([]);
|
||||||
return loadTemplates() ?? [];
|
|
||||||
});
|
|
||||||
const [templateBuilderFormat, setTemplateBuilderFormat] = useState<'video' | 'image'>('image');
|
const [templateBuilderFormat, setTemplateBuilderFormat] = useState<'video' | 'image'>('image');
|
||||||
const [templateBuilderAspect, setTemplateBuilderAspect] = useState<ExpressTemplate['aspectRatio']>('9:16');
|
const [templateBuilderAspect, setTemplateBuilderAspect] = useState<ExpressTemplate['aspectRatio']>('9:16');
|
||||||
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState<ExpressTemplate | null>(null);
|
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState<ExpressTemplate | null>(null);
|
||||||
@@ -64,7 +60,8 @@ export default function App() {
|
|||||||
...globalTemplates,
|
...globalTemplates,
|
||||||
], [globalTemplates]);
|
], [globalTemplates]);
|
||||||
|
|
||||||
const handleSaveGlobalTemplate = useCallback((template: ExpressTemplate) => {
|
const handleSaveGlobalTemplate = useCallback(async (template: ExpressTemplate) => {
|
||||||
|
// Optimistic update
|
||||||
setGlobalTemplates(prev => {
|
setGlobalTemplates(prev => {
|
||||||
const existing = prev.findIndex(t => t.id === template.id);
|
const existing = prev.findIndex(t => t.id === template.id);
|
||||||
if (existing >= 0) {
|
if (existing >= 0) {
|
||||||
@@ -74,33 +71,22 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return [...prev, template];
|
return [...prev, template];
|
||||||
});
|
});
|
||||||
|
// Persist to FS
|
||||||
|
if (window.electronAPI) {
|
||||||
|
await window.electronAPI.fs.saveTemplate(template);
|
||||||
|
}
|
||||||
setEditingGlobalTemplate(null);
|
setEditingGlobalTemplate(null);
|
||||||
setCurrentStep('dashboard');
|
setCurrentStep('dashboard');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Content grid state (per company)
|
// Global Content Mesh state
|
||||||
const [contentData, setContentData] = useState<Record<string, { pieces: ContentPiece[]; pillars: ContentPillar[] }>>(() => {
|
const [globalContentMesh, setGlobalContentMesh] = useState<any>({});
|
||||||
return loadContentData() ?? {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const getContentForCompany = useCallback((companyId: string) => {
|
const updateGlobalContentMesh = useCallback((newMesh: any) => {
|
||||||
return contentData[companyId] ?? { pieces: [], pillars: [...DEFAULT_PILLARS] };
|
setGlobalContentMesh(newMesh);
|
||||||
}, [contentData]);
|
if (window.electronAPI) {
|
||||||
|
window.electronAPI.fs.saveContentMesh(newMesh);
|
||||||
const updateContentPieces = useCallback((companyId: string, pieces: ContentPiece[]) => {
|
}
|
||||||
setContentData(prev => {
|
|
||||||
const next = { ...prev, [companyId]: { ...prev[companyId] ?? { pillars: [...DEFAULT_PILLARS] }, pieces } };
|
|
||||||
saveContentData(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateContentPillars = useCallback((companyId: string, pillars: ContentPillar[]) => {
|
|
||||||
setContentData(prev => {
|
|
||||||
const next = { ...prev, [companyId]: { ...prev[companyId] ?? { pieces: [] }, pillars } };
|
|
||||||
saveContentData(next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Studio initial data (passed to EditorProvider when entering studio)
|
// Studio initial data (passed to EditorProvider when entering studio)
|
||||||
@@ -112,17 +98,58 @@ export default function App() {
|
|||||||
const [editorKey, setEditorKey] = useState(0);
|
const [editorKey, setEditorKey] = useState(0);
|
||||||
|
|
||||||
useCustomTooltips();
|
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) => {
|
setDesignMD((prev) => {
|
||||||
const newDesign = { ...prev, [key]: value };
|
const newDesign = { ...prev, [key]: value };
|
||||||
if (currentCompanyId) {
|
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;
|
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) => {
|
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(),
|
createdAt: new Date().toISOString(),
|
||||||
scenes: template.scenes.map(s => ({ ...s, id: `scene-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` })),
|
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) => {
|
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<string, string>) => {
|
const handleProducePro = useCallback((fieldData: Record<string, string>) => {
|
||||||
@@ -276,12 +311,14 @@ export default function App() {
|
|||||||
projects: []
|
projects: []
|
||||||
};
|
};
|
||||||
setCompanies(prev => [...prev, newBrand]);
|
setCompanies(prev => [...prev, newBrand]);
|
||||||
|
if (window.electronAPI) window.electronAPI.fs.saveBrand(newBrand);
|
||||||
setCurrentCompanyId(newAppId);
|
setCurrentCompanyId(newAppId);
|
||||||
setDesignMD(newBrand.design);
|
setDesignMD(newBrand.design);
|
||||||
setCurrentStep('brand');
|
setCurrentStep('brand');
|
||||||
}}
|
}}
|
||||||
onDeleteBrand={(id) => {
|
onDeleteBrand={(id) => {
|
||||||
setCompanies(prev => prev.filter(c => c.id !== id));
|
setCompanies(prev => prev.filter(c => c.id !== id));
|
||||||
|
if (window.electronAPI) window.electronAPI.fs.deleteBrand(id);
|
||||||
}}
|
}}
|
||||||
onDuplicateBrand={(id) => {
|
onDuplicateBrand={(id) => {
|
||||||
const original = companies.find(c => c.id === id);
|
const original = companies.find(c => c.id === id);
|
||||||
@@ -296,6 +333,7 @@ export default function App() {
|
|||||||
socialLinks: original.socialLinks ? { ...original.socialLinks } : undefined,
|
socialLinks: original.socialLinks ? { ...original.socialLinks } : undefined,
|
||||||
};
|
};
|
||||||
setCompanies(prev => [...prev, duplicate]);
|
setCompanies(prev => [...prev, duplicate]);
|
||||||
|
if (window.electronAPI) window.electronAPI.fs.saveBrand(duplicate);
|
||||||
}}
|
}}
|
||||||
onEditBrand={(design) => {
|
onEditBrand={(design) => {
|
||||||
const comp = companies.find(c => c.design === design);
|
const comp = companies.find(c => c.design === design);
|
||||||
@@ -334,6 +372,7 @@ export default function App() {
|
|||||||
company={companies.find(c => c.id === currentCompanyId)!}
|
company={companies.find(c => c.id === currentCompanyId)!}
|
||||||
handleCompanyChange={(company) => {
|
handleCompanyChange={(company) => {
|
||||||
setCompanies(prev => prev.map(c => c.id === company.id ? company : c));
|
setCompanies(prev => prev.map(c => c.id === company.id ? company : c));
|
||||||
|
if (window.electronAPI) window.electronAPI.fs.saveBrand(company);
|
||||||
}}
|
}}
|
||||||
designMD={designMD}
|
designMD={designMD}
|
||||||
handleDesignChange={handleDesignChange}
|
handleDesignChange={handleDesignChange}
|
||||||
@@ -342,15 +381,13 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 'content-grid' && currentCompanyId && (
|
{currentStep === 'content-grid' && (
|
||||||
<ContentGridView
|
<ContentGridView
|
||||||
company={companies.find(c => c.id === currentCompanyId)!}
|
companies={companies}
|
||||||
pieces={getContentForCompany(currentCompanyId).pieces}
|
contentMesh={globalContentMesh}
|
||||||
pillars={getContentForCompany(currentCompanyId).pillars}
|
onContentMeshChange={updateGlobalContentMesh}
|
||||||
onPiecesChange={(pieces) => updateContentPieces(currentCompanyId, pieces)}
|
onOpenProject={(projectId, companyId) => {
|
||||||
onPillarsChange={(pillars) => updateContentPillars(currentCompanyId, pillars)}
|
const comp = companies.find(c => c.id === companyId);
|
||||||
onOpenProject={(projectId) => {
|
|
||||||
const comp = companies.find(c => c.id === currentCompanyId);
|
|
||||||
if (comp) {
|
if (comp) {
|
||||||
const proj = comp.projects.find(p => p.id === projectId);
|
const proj = comp.projects.find(p => p.id === projectId);
|
||||||
if (proj) {
|
if (proj) {
|
||||||
@@ -386,7 +423,7 @@ export default function App() {
|
|||||||
initialLayers={studioInitialLayers}
|
initialLayers={studioInitialLayers}
|
||||||
initialFormat={outputFormat}
|
initialFormat={outputFormat}
|
||||||
initialAspect={templateBuilderAspect}
|
initialAspect={templateBuilderAspect}
|
||||||
brandContent={currentCompanyId ? (getContentForCompany(currentCompanyId).pieces || []) : []}
|
brandContent={[]} // TODO: Adapt if needed for global mesh
|
||||||
editingBrandAsset={editingBrandAsset}
|
editingBrandAsset={editingBrandAsset}
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
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 { DesignMD, CompanyProfile } from '../types';
|
||||||
import { BrandTabGeneral } from './brand/BrandTabGeneral';
|
import { BrandTabGeneral } from './brand/BrandTabGeneral';
|
||||||
import { BrandTabVisual } from './brand/BrandTabVisual';
|
import { BrandTabVisual } from './brand/BrandTabVisual';
|
||||||
import { BrandTabTypography } from './brand/BrandTabTypography';
|
import { BrandTabTypography } from './brand/BrandTabTypography';
|
||||||
import { BrandTabMedia } from './brand/BrandTabMedia';
|
import { BrandTabMedia } from './brand/BrandTabMedia';
|
||||||
|
import { BrandTabGenerated } from './brand/BrandTabGenerated';
|
||||||
import { BrandPreview } from './brand/BrandPreview';
|
import { BrandPreview } from './brand/BrandPreview';
|
||||||
import { Toast } from './ui/Toast';
|
import { Toast } from './ui/Toast';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ const TABS = [
|
|||||||
{ id: 'visual', label: 'Visual y Colores', icon: '🎨' },
|
{ id: 'visual', label: 'Visual y Colores', icon: '🎨' },
|
||||||
{ id: 'typography', label: 'Tipografía', icon: '🔤' },
|
{ id: 'typography', label: 'Tipografía', icon: '🔤' },
|
||||||
{ id: 'media', label: 'Video y Audio', icon: '🎬' },
|
{ id: 'media', label: 'Video y Audio', icon: '🎬' },
|
||||||
|
{ id: 'generated', label: 'Generados', icon: '✨' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type TabId = typeof TABS[number]['id'];
|
type TabId = typeof TABS[number]['id'];
|
||||||
@@ -59,7 +61,13 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ 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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col w-full overflow-hidden">
|
<div className="flex-1 flex flex-col w-full overflow-hidden">
|
||||||
{/* ═══ Sticky Header: Title + Brand Identity ═══ */}
|
{/* ═══ Sticky Header: Title + Brand Identity ═══ */}
|
||||||
@@ -120,6 +128,14 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenFolder}
|
||||||
|
title="Abrir carpeta local"
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-neutral-800/80 hover:bg-neutral-700/80 border border-neutral-700/50 text-neutral-300 text-sm font-medium transition-all"
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Save Button */}
|
{/* Save Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -185,6 +201,7 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'visual' && (
|
{activeTab === 'visual' && (
|
||||||
<BrandTabVisual
|
<BrandTabVisual
|
||||||
|
company={company}
|
||||||
designMD={designMD}
|
designMD={designMD}
|
||||||
handleDesignChange={handleDesignChange}
|
handleDesignChange={handleDesignChange}
|
||||||
onEditAsset={onEditAsset}
|
onEditAsset={onEditAsset}
|
||||||
@@ -195,28 +212,37 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'media' && (
|
{activeTab === 'media' && (
|
||||||
<BrandTabMedia
|
<BrandTabMedia
|
||||||
|
company={company}
|
||||||
designMD={designMD}
|
designMD={designMD}
|
||||||
handleDesignChange={handleDesignChange}
|
handleDesignChange={handleDesignChange}
|
||||||
onEditAsset={onEditAsset}
|
onEditAsset={onEditAsset}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'generated' && (
|
||||||
|
<BrandTabGenerated company={company} />
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Column */}
|
{/* Preview Column */}
|
||||||
<BrandPreview
|
{activeTab === 'generated' ? (
|
||||||
designMD={designMD}
|
<div className="flex-1 bg-neutral-950 flex flex-col items-center justify-center text-neutral-500">
|
||||||
company={company}
|
<Sparkles className="w-12 h-12 mb-4 opacity-50" />
|
||||||
activeTab={activeTab}
|
<p>Selecciona un archivo generado para previsualizarlo.</p>
|
||||||
zoom={zoom}
|
</div>
|
||||||
setZoom={setZoom}
|
) : (
|
||||||
aspectRatio={aspectRatio}
|
<BrandPreview
|
||||||
setAspectRatio={setAspectRatio}
|
designMD={designMD}
|
||||||
handleDesignChange={handleDesignChange}
|
company={company}
|
||||||
/>
|
activeTab={activeTab}
|
||||||
|
zoom={zoom}
|
||||||
|
setZoom={setZoom}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
setAspectRatio={setAspectRatio}
|
||||||
|
handleDesignChange={handleDesignChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success Toast */}
|
{/* Success Toast */}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles, FolderCog } from 'lucide-react';
|
||||||
import { DesignMD, CompanyProfile, ExpressTemplate } from '../types';
|
import { DesignMD, CompanyProfile, ExpressTemplate } from '../types';
|
||||||
import { TemplatesPanel, TemplateDragPreview } from './dashboard/TemplatesPanel';
|
import { TemplatesPanel, TemplateDragPreview } from './dashboard/TemplatesPanel';
|
||||||
import { BrandsPanel, BrandDragPreview } from './dashboard/BrandsPanel';
|
import { BrandsPanel, BrandDragPreview } from './dashboard/BrandsPanel';
|
||||||
@@ -60,6 +60,26 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
const [selectedBrand, setSelectedBrand] = useState<CompanyProfile | null>(null);
|
const [selectedBrand, setSelectedBrand] = useState<CompanyProfile | null>(null);
|
||||||
const [activeDrag, setActiveDrag] = useState<DragItem | null>(null);
|
const [activeDrag, setActiveDrag] = useState<DragItem | null>(null);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [workspacePath, setWorkspacePath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
window.electronAPI.fs.getWorkspacePath().then(setWorkspacePath);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangeWorkspace = async () => {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
const newPath = await window.electronAPI.fs.setWorkspacePath();
|
||||||
|
if (newPath) {
|
||||||
|
setWorkspacePath(newPath);
|
||||||
|
// Force reload of brands and templates by the parent if possible,
|
||||||
|
// or just let the user know they might need to restart/reload.
|
||||||
|
// For now, we'll just reload the window to ensure fresh state.
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// DnD sensor config — require 5px movement before starting drag (allows click)
|
// DnD sensor config — require 5px movement before starting drag (allows click)
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -117,16 +137,16 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-y-auto w-full relative bg-neutral-950">
|
<div className="flex-1 overflow-hidden w-full relative bg-neutral-950 flex flex-col">
|
||||||
{/* Subtle grid background */}
|
{/* Subtle grid background */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.03]"
|
className="absolute inset-0 opacity-[0.03]"
|
||||||
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
|
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '40px 40px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="max-w-6xl w-full mx-auto p-8 relative z-10">
|
<div className="w-full max-w-[1600px] mx-auto p-8 lg:p-10 xl:p-12 relative z-10 flex flex-col h-full overflow-hidden">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="mb-8">
|
<div className="mb-8 shrink-0">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-violet-900/30">
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-violet-900/30">
|
||||||
<Sparkles size={20} className="text-white" />
|
<Sparkles size={20} className="text-white" />
|
||||||
@@ -135,11 +155,29 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
<h1 className="text-2xl font-bold text-white tracking-tight">Crear Contenido</h1>
|
<h1 className="text-2xl font-bold text-white tracking-tight">Crear Contenido</h1>
|
||||||
<p className="text-sm text-neutral-500">Combina una plantilla con una marca para generar contenido</p>
|
<p className="text-sm text-neutral-500">Combina una plantilla con una marca para generar contenido</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{workspacePath && (
|
||||||
|
<div className="flex flex-col items-end mr-4">
|
||||||
|
<span className="text-[10px] text-neutral-500 uppercase font-semibold tracking-wider">Espacio de trabajo</span>
|
||||||
|
<span className="text-xs text-neutral-300 font-mono" title={workspacePath}>
|
||||||
|
{workspacePath.length > 35 ? '...' + workspacePath.slice(-35) : workspacePath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleChangeWorkspace}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-neutral-900 border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-800 rounded-xl text-sm font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
<FolderCog size={16} className="text-neutral-400" />
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Zone 1 & 2: Templates + Brands (side by side) ── */}
|
{/* ── Zone 1 & 2: Templates + Brands (side by side) ── */}
|
||||||
<div className="flex gap-5 mb-6" style={{ height: 380 }}>
|
<div className="flex gap-6 lg:gap-8 mb-8 flex-1 min-h-[450px]">
|
||||||
<TemplatesPanel
|
<TemplatesPanel
|
||||||
templates={templates}
|
templates={templates}
|
||||||
onSelect={handleSelectTemplate}
|
onSelect={handleSelectTemplate}
|
||||||
@@ -160,15 +198,17 @@ export const Dashboard: React.FC<DashboardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Zone 3: Generate Content ── */}
|
{/* ── Zone 3: Generate Content ── */}
|
||||||
<GenerateZone
|
<div className="shrink-0 pb-8">
|
||||||
selectedTemplate={selectedTemplate}
|
<GenerateZone
|
||||||
selectedBrand={selectedBrand}
|
selectedTemplate={selectedTemplate}
|
||||||
onClearTemplate={() => setSelectedTemplate(null)}
|
selectedBrand={selectedBrand}
|
||||||
onClearBrand={() => setSelectedBrand(null)}
|
onClearTemplate={() => setSelectedTemplate(null)}
|
||||||
onClickTemplateSlot={() => {/* Could open a modal selector — for now click on panel */}}
|
onClearBrand={() => setSelectedBrand(null)}
|
||||||
onClickBrandSlot={() => {/* Could open a modal selector — for now click on panel */}}
|
onClickTemplateSlot={() => {/* Could open a modal selector — for now click on panel */}}
|
||||||
onGenerate={handleGenerate}
|
onClickBrandSlot={() => {/* Could open a modal selector — for now click on panel */}}
|
||||||
/>
|
onGenerate={handleGenerate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 {
|
interface TopHeaderProps {
|
||||||
currentStep: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
|
currentStep: 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
|
||||||
@@ -84,7 +84,7 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
|
|||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-neutral-300 hover:bg-neutral-700 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<Download size={14} /> Descargar
|
<FolderOpen size={14} /> Abrir
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -172,6 +172,17 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
|
|||||||
Editor Pro 🎛️
|
Editor Pro 🎛️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentStep !== 'content-grid' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentStep('content-grid')}
|
||||||
|
title="Abrir malla de contenidos"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 text-white text-[10px] font-semibold transition-all"
|
||||||
|
>
|
||||||
|
<CalendarDays size={12} />
|
||||||
|
Malla de Contenidos
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isStudio && (
|
{isStudio && (
|
||||||
<span className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">
|
<span className="text-[10px] font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
|||||||
@@ -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<BrandTabGeneratedProps> = ({ company }) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2 flex items-center gap-2">
|
||||||
|
Contenido Generado
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-400">
|
||||||
|
Archivos renderizados y guardados para la marca {company.name}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<GeneratedMediaList brandId={company.id} draggable={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Film, Volume2, Music, X, Upload, Wand2, Maximize2, Minimize2, Move, Pipette } from 'lucide-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';
|
import { FileDropZone } from '../ui/FileDropZone';
|
||||||
|
|
||||||
interface BrandTabMediaProps {
|
interface BrandTabMediaProps {
|
||||||
|
company: CompanyProfile;
|
||||||
designMD: DesignMD;
|
designMD: DesignMD;
|
||||||
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
|
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
|
* All positioning, fit, duration, and blend controls live in the TemplateBuilder
|
||||||
* (per-template segment configuration), avoiding collisions.
|
* (per-template segment configuration), avoiding collisions.
|
||||||
*/
|
*/
|
||||||
export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type: keyof DesignMD, url: string) => void }> = ({ designMD, handleDesignChange, onEditAsset }) => {
|
export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type: keyof DesignMD, url: string) => void }> = ({ company, designMD, handleDesignChange, onEditAsset }) => {
|
||||||
|
|
||||||
/** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
|
/** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
|
||||||
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
|
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
|
||||||
@@ -48,6 +49,7 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
|
|||||||
|
|
||||||
{/* ═══ Intro Video ═══ */}
|
{/* ═══ Intro Video ═══ */}
|
||||||
<VideoUploadSimple
|
<VideoUploadSimple
|
||||||
|
company={company}
|
||||||
label="Video de Cabezote (Intro)"
|
label="Video de Cabezote (Intro)"
|
||||||
description="Se usará automáticamente en plantillas que incluyan segmento de intro de marca"
|
description="Se usará automáticamente en plantillas que incluyan segmento de intro de marca"
|
||||||
videoUrl={designMD.introVideoUrl || ''}
|
videoUrl={designMD.introVideoUrl || ''}
|
||||||
@@ -70,6 +72,7 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
|
|||||||
|
|
||||||
{/* ═══ Outro Video ═══ */}
|
{/* ═══ Outro Video ═══ */}
|
||||||
<VideoUploadSimple
|
<VideoUploadSimple
|
||||||
|
company={company}
|
||||||
label="Video de Cierre (Outro)"
|
label="Video de Cierre (Outro)"
|
||||||
description="Se usará automáticamente en plantillas que incluyan segmento de outro de marca"
|
description="Se usará automáticamente en plantillas que incluyan segmento de outro de marca"
|
||||||
videoUrl={designMD.outroVideoUrl || ''}
|
videoUrl={designMD.outroVideoUrl || ''}
|
||||||
@@ -144,10 +147,25 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
|
|||||||
accept="audio/*"
|
accept="audio/*"
|
||||||
label="Subir audio"
|
label="Subir audio"
|
||||||
onFiles={async (files) => {
|
onFiles={async (files) => {
|
||||||
|
let workspacePath = '';
|
||||||
|
if (window.electronAPI) {
|
||||||
|
workspacePath = await window.electronAPI.fs.getWorkspacePath();
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', files[0]);
|
formData.append('file', files[0]);
|
||||||
|
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
if (data.url) handleDesignChange('brandAudioUrl', data.url);
|
if (data.url) handleDesignChange('brandAudioUrl', data.url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -184,6 +202,7 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
|
|||||||
/* ── Simple Video Upload Card ── */
|
/* ── Simple Video Upload Card ── */
|
||||||
|
|
||||||
const VideoUploadSimple: React.FC<{
|
const VideoUploadSimple: React.FC<{
|
||||||
|
company: CompanyProfile;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
@@ -196,7 +215,7 @@ const VideoUploadSimple: React.FC<{
|
|||||||
onFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
|
onFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
|
||||||
bgColor?: string | null;
|
bgColor?: string | null;
|
||||||
onBgColorChange?: (color: string | null) => void;
|
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 hasVideo = !!videoUrl && videoUrl.trim().length > 0;
|
||||||
const colorInputRef = React.useRef<HTMLInputElement>(null);
|
const colorInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -258,10 +277,25 @@ const VideoUploadSimple: React.FC<{
|
|||||||
accept="video/*"
|
accept="video/*"
|
||||||
label="Subir archivo"
|
label="Subir archivo"
|
||||||
onFiles={async (files) => {
|
onFiles={async (files) => {
|
||||||
|
let workspacePath = '';
|
||||||
|
if (window.electronAPI) {
|
||||||
|
workspacePath = await window.electronAPI.fs.getWorkspacePath();
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', files[0]);
|
formData.append('file', files[0]);
|
||||||
|
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
if (data.url) onUrlChange(data.url);
|
if (data.url) onUrlChange(data.url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Settings2, ImageIcon, Wand2 } from 'lucide-react';
|
import { Settings2, ImageIcon, Wand2 } from 'lucide-react';
|
||||||
import { DesignMD } from '../../types';
|
import { DesignMD, CompanyProfile } from '../../types';
|
||||||
import { FileDropZone } from '../ui/FileDropZone';
|
import { FileDropZone } from '../ui/FileDropZone';
|
||||||
|
|
||||||
interface BrandTabVisualProps {
|
interface BrandTabVisualProps {
|
||||||
|
company: CompanyProfile;
|
||||||
designMD: DesignMD;
|
designMD: DesignMD;
|
||||||
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
|
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
|
||||||
onEditAsset?: (type: keyof DesignMD, url: string) => void;
|
onEditAsset?: (type: keyof DesignMD, url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
|
export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
|
||||||
|
company,
|
||||||
designMD,
|
designMD,
|
||||||
handleDesignChange,
|
handleDesignChange,
|
||||||
onEditAsset,
|
onEditAsset,
|
||||||
@@ -19,9 +21,23 @@ export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let workspacePath = '';
|
||||||
|
if (window.electronAPI) {
|
||||||
|
workspacePath = await window.electronAPI.fs.getWorkspacePath();
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
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');
|
if (!res.ok) throw new Error('Upload failed');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
handleDesignChange('logoUrl', data.url);
|
handleDesignChange('logoUrl', data.url);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Image as ImageIcon, Play } from 'lucide-react';
|
||||||
import { ContentPiece, ContentPillar } from '../../types';
|
import { CompanyProfile } from '../../types';
|
||||||
import { ContentCard } from './ContentCard';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
|
||||||
interface CalendarViewProps {
|
interface CalendarViewProps {
|
||||||
pieces: ContentPiece[];
|
contentMesh: any;
|
||||||
pillars: ContentPillar[];
|
onContentMeshChange: (mesh: any) => void;
|
||||||
onPieceClick: (piece: ContentPiece) => void;
|
companies: CompanyProfile[];
|
||||||
onCreatePiece: (date: string) => void;
|
filterBrandId: string;
|
||||||
onDropPiece: (pieceId: string, newDate: string) => void;
|
onSelectDate: (date: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
const DAYS_ES = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
|
||||||
@@ -17,73 +17,74 @@ const MONTHS_ES = [
|
|||||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Monthly calendar view inspired by Later/Planable.
|
|
||||||
* Shows content pieces in day cells with drag-and-drop rescheduling.
|
|
||||||
*/
|
|
||||||
export const CalendarView: React.FC<CalendarViewProps> = ({
|
export const CalendarView: React.FC<CalendarViewProps> = ({
|
||||||
pieces,
|
contentMesh,
|
||||||
pillars,
|
onContentMeshChange,
|
||||||
onPieceClick,
|
companies,
|
||||||
onCreatePiece,
|
filterBrandId,
|
||||||
onDropPiece,
|
onSelectDate,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const [dragOverDate, setDragOverDate] = useState<string | null>(null);
|
const [calendarType, setCalendarType] = useState<'month' | 'week'>('month');
|
||||||
|
|
||||||
const year = currentDate.getFullYear();
|
const yearNum = currentDate.getFullYear();
|
||||||
const month = currentDate.getMonth();
|
const monthNum = currentDate.getMonth();
|
||||||
|
|
||||||
// Generate calendar grid (6 weeks × 7 days)
|
// Generate calendar grid
|
||||||
const calendarDays = useMemo(() => {
|
const calendarDays = useMemo(() => {
|
||||||
const firstDay = new Date(year, month, 1);
|
if (calendarType === 'month') {
|
||||||
// Adjust so Monday = 0
|
const firstDay = new Date(yearNum, monthNum, 1);
|
||||||
const startDow = (firstDay.getDay() + 6) % 7;
|
// Adjust so Monday = 0
|
||||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
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
|
// Previous month fill
|
||||||
const prevMonthDays = new Date(year, month, 0).getDate();
|
const prevMonthDays = new Date(yearNum, monthNum, 0).getDate();
|
||||||
for (let i = startDow - 1; i >= 0; i--) {
|
for (let i = startDow - 1; i >= 0; i--) {
|
||||||
days.push({
|
days.push({
|
||||||
date: new Date(year, month - 1, prevMonthDays - i),
|
date: new Date(yearNum, monthNum - 1, prevMonthDays - i),
|
||||||
isCurrentMonth: false,
|
isCurrentMonth: false,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Current month
|
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
|
||||||
days.push({
|
|
||||||
date: new Date(year, month, d),
|
|
||||||
isCurrentMonth: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next month fill (to complete 6 rows)
|
|
||||||
const remaining = 42 - days.length;
|
|
||||||
for (let d = 1; d <= remaining; d++) {
|
|
||||||
days.push({
|
|
||||||
date: new Date(year, month + 1, d),
|
|
||||||
isCurrentMonth: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return days;
|
|
||||||
}, [year, month]);
|
|
||||||
|
|
||||||
// Group pieces by date
|
|
||||||
const piecesByDate = useMemo(() => {
|
|
||||||
const map: Record<string, ContentPiece[]> = {};
|
|
||||||
pieces.forEach(p => {
|
|
||||||
if (p.scheduledDate) {
|
|
||||||
const key = p.scheduledDate;
|
|
||||||
if (!map[key]) map[key] = [];
|
|
||||||
map[key].push(p);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
return map;
|
// Current month
|
||||||
}, [pieces]);
|
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) => {
|
const toDateKey = (date: Date) => {
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
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<CalendarViewProps> = ({
|
|||||||
date.getFullYear() === today.getFullYear();
|
date.getFullYear() === today.getFullYear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToPrev = () => setCurrentDate(new Date(year, month - 1, 1));
|
const goToPrev = () => {
|
||||||
const goToNext = () => setCurrentDate(new Date(year, month + 1, 1));
|
if (calendarType === 'month') {
|
||||||
const goToToday = () => setCurrentDate(new Date());
|
setCurrentDate(new Date(yearNum, monthNum - 1, 1));
|
||||||
|
} else {
|
||||||
const handleDragOver = useCallback((e: React.DragEvent, dateKey: string) => {
|
const prevWeek = new Date(currentDate);
|
||||||
e.preventDefault();
|
prevWeek.setDate(currentDate.getDate() - 7);
|
||||||
e.dataTransfer.dropEffect = 'move';
|
setCurrentDate(prevWeek);
|
||||||
setDragOverDate(dateKey);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent, dateKey: string) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const pieceId = e.dataTransfer.getData('text/piece-id');
|
|
||||||
if (pieceId) {
|
|
||||||
onDropPiece(pieceId, dateKey);
|
|
||||||
}
|
}
|
||||||
setDragOverDate(null);
|
};
|
||||||
}, [onDropPiece]);
|
|
||||||
|
const goToNext = () => {
|
||||||
const handleDragStart = useCallback((e: React.DragEvent, piece: ContentPiece) => {
|
if (calendarType === 'month') {
|
||||||
e.dataTransfer.setData('text/piece-id', piece.id);
|
setCurrentDate(new Date(yearNum, monthNum + 1, 1));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
} else {
|
||||||
}, []);
|
const nextWeek = new Date(currentDate);
|
||||||
|
nextWeek.setDate(currentDate.getDate() + 7);
|
||||||
|
setCurrentDate(nextWeek);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const goToToday = () => setCurrentDate(new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Calendar Header */}
|
{/* Calendar Header */}
|
||||||
<div className="flex items-center justify-between px-1 pb-4">
|
<div className="flex items-center justify-between px-1 pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-lg font-bold text-white">
|
<h3 className="text-lg font-bold text-white min-w-[150px]">
|
||||||
{MONTHS_ES[month]} {year}
|
{MONTHS_ES[monthNum]} {yearNum}
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex bg-neutral-900 border border-neutral-800 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setCalendarType('month')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
calendarType === 'month' ? 'bg-violet-600 text-white' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCalendarType('week')}
|
||||||
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
calendarType === 'week' ? 'bg-violet-600 text-white' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Semana
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={goToToday}
|
onClick={goToToday}
|
||||||
className="px-2 py-1 text-[10px] font-semibold text-violet-400 bg-violet-600/10 border border-violet-500/20 rounded-lg hover:bg-violet-600/20 transition-all"
|
className="px-2 py-1 ml-2 text-[10px] font-semibold text-violet-400 bg-violet-600/10 border border-violet-500/20 rounded-lg hover:bg-violet-600/20 transition-all"
|
||||||
title="Ir a hoy"
|
title="Ir a hoy"
|
||||||
>
|
>
|
||||||
Hoy
|
Hoy
|
||||||
@@ -164,72 +180,120 @@ export const CalendarView: React.FC<CalendarViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar grid */}
|
{/* Calendar grid */}
|
||||||
<div className="grid grid-cols-7 gap-px flex-1 bg-neutral-800/30 rounded-xl overflow-hidden border border-neutral-800/50">
|
<div className={`grid grid-cols-7 gap-px flex-1 bg-neutral-800/30 rounded-xl overflow-hidden border border-neutral-800/50 ${calendarType === 'week' ? 'grid-rows-1' : ''}`}>
|
||||||
{calendarDays.map(({ date, isCurrentMonth }, idx) => {
|
{calendarDays.map(({ date, isCurrentMonth }, idx) => {
|
||||||
const dateKey = toDateKey(date);
|
const dateKey = toDateKey(date);
|
||||||
const dayPieces = piecesByDate[dateKey] || [];
|
|
||||||
const today = isToday(date);
|
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 (
|
return (
|
||||||
<div
|
<DayCell
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`min-h-[100px] p-1.5 flex flex-col transition-colors ${
|
date={date}
|
||||||
isCurrentMonth
|
dateKey={dateKey}
|
||||||
? 'bg-neutral-950/80'
|
isCurrentMonth={isCurrentMonth}
|
||||||
: 'bg-neutral-950/40'
|
today={today}
|
||||||
} ${isDragOver ? 'bg-violet-950/30 ring-1 ring-inset ring-violet-500/40' : ''}`}
|
itemsCount={itemsCount}
|
||||||
onDragOver={(e) => handleDragOver(e, dateKey)}
|
dayData={dayData}
|
||||||
onDragLeave={() => setDragOverDate(null)}
|
filterBrandId={filterBrandId}
|
||||||
onDrop={(e) => handleDrop(e, dateKey)}
|
companies={companies}
|
||||||
>
|
onClick={() => onSelectDate(dateKey)}
|
||||||
{/* Day number */}
|
/>
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span
|
|
||||||
className={`text-[11px] font-semibold w-6 h-6 flex items-center justify-center rounded-full transition-colors ${
|
|
||||||
today
|
|
||||||
? 'bg-violet-600 text-white'
|
|
||||||
: isCurrentMonth
|
|
||||||
? 'text-neutral-300'
|
|
||||||
: 'text-neutral-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{date.getDate()}
|
|
||||||
</span>
|
|
||||||
{isCurrentMonth && (
|
|
||||||
<button
|
|
||||||
onClick={() => onCreatePiece(dateKey)}
|
|
||||||
className="w-4 h-4 rounded flex items-center justify-center text-neutral-700 hover:text-violet-400 hover:bg-violet-600/10 transition-all opacity-0 hover:opacity-100 focus:opacity-100"
|
|
||||||
title="Crear contenido en este día"
|
|
||||||
>
|
|
||||||
<Plus size={10} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content pieces */}
|
|
||||||
<div className="space-y-0.5 flex-1 overflow-y-auto custom-scrollbar">
|
|
||||||
{dayPieces.slice(0, 3).map(piece => (
|
|
||||||
<ContentCard
|
|
||||||
key={piece.id}
|
|
||||||
piece={piece}
|
|
||||||
pillar={pillars.find(p => p.id === piece.pillarId)}
|
|
||||||
onClick={onPieceClick}
|
|
||||||
compact
|
|
||||||
draggable
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{dayPieces.length > 3 && (
|
|
||||||
<span className="text-[9px] text-neutral-600 font-mono px-1">
|
|
||||||
+{dayPieces.length - 3} más
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<DayCellProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`min-h-[100px] p-1.5 flex flex-col transition-colors cursor-pointer ${
|
||||||
|
isCurrentMonth
|
||||||
|
? 'bg-neutral-950/80 hover:bg-neutral-900'
|
||||||
|
: 'bg-neutral-950/40 hover:bg-neutral-900/60'
|
||||||
|
} ${isOver ? 'bg-violet-950/30 ring-2 ring-inset ring-violet-500' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span
|
||||||
|
className={`text-[11px] font-semibold w-6 h-6 flex items-center justify-center rounded-full transition-colors ${
|
||||||
|
today
|
||||||
|
? 'bg-violet-600 text-white'
|
||||||
|
: isCurrentMonth
|
||||||
|
? 'text-neutral-300'
|
||||||
|
: 'text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
{itemsCount > 0 && (
|
||||||
|
<span className="text-[10px] font-medium text-violet-400 bg-violet-500/10 px-1.5 rounded">
|
||||||
|
{itemsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-1 overflow-hidden mt-1">
|
||||||
|
{filteredVideos.slice(0, 2).map((v, i) => {
|
||||||
|
const displayName = filterBrandId ? v.original_name : `${getBrandName(v.mark_id)} - ${v.original_name}`;
|
||||||
|
return (
|
||||||
|
<div key={`v-${i}`} className="text-[9px] bg-indigo-500/10 text-indigo-300 px-1 py-0.5 rounded flex items-center gap-1 truncate" title={displayName}>
|
||||||
|
<Play size={8} className="shrink-0" /> <span className="truncate">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredImages.slice(0, 2).map((img, i) => {
|
||||||
|
const displayName = filterBrandId ? img.original_name : `${getBrandName(img.mark_id)} - ${img.original_name}`;
|
||||||
|
return (
|
||||||
|
<div key={`i-${i}`} className="text-[9px] bg-fuchsia-500/10 text-fuchsia-300 px-1 py-0.5 rounded flex items-center gap-1 truncate" title={displayName}>
|
||||||
|
<ImageIcon size={8} className="shrink-0" /> <span className="truncate">{displayName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{itemsCount > 4 && (
|
||||||
|
<div className="text-[9px] text-neutral-500 font-medium pl-1">
|
||||||
|
+{itemsCount - 4} más
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,321 +1,168 @@
|
|||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import { DndContext, DragEndEvent } from '@dnd-kit/core';
|
||||||
CalendarDays, LayoutGrid, List, Plus, Settings2, Sparkles,
|
import { CompanyProfile } from '../../types';
|
||||||
BarChart3, TrendingUp
|
import { ContentMeshSidebar } from './ContentMeshSidebar';
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
ContentPiece, ContentPillar, ContentStatus, Platform, CompanyProfile
|
|
||||||
} from '../../types';
|
|
||||||
import { DEFAULT_PILLARS } from '../../data/defaults';
|
|
||||||
import { ContentFilters } from './ContentFilters';
|
|
||||||
import { CalendarView } from './CalendarView';
|
import { CalendarView } from './CalendarView';
|
||||||
import { GridView } from './GridView';
|
import { DailyTimelineView } from './DailyTimelineView';
|
||||||
import { ListView } from './ListView';
|
|
||||||
import { ContentDetailModal } from './ContentDetailModal';
|
|
||||||
import { PillarManager } from './PillarManager';
|
|
||||||
|
|
||||||
type ViewMode = 'calendar' | 'grid' | 'list';
|
|
||||||
|
|
||||||
interface ContentGridViewProps {
|
interface ContentGridViewProps {
|
||||||
company: CompanyProfile;
|
companies: CompanyProfile[];
|
||||||
pieces: ContentPiece[];
|
contentMesh: any;
|
||||||
pillars: ContentPillar[];
|
onContentMeshChange: (mesh: any) => void;
|
||||||
onPiecesChange: (pieces: ContentPiece[]) => void;
|
onOpenProject: (projectId: string, companyId: string) => void;
|
||||||
onPillarsChange: (pillars: ContentPillar[]) => void;
|
|
||||||
onOpenProject: (projectId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Main content grid view with three visualization modes.
|
|
||||||
* Orchestrates Calendar, Grid, and List views with shared filters.
|
|
||||||
*/
|
|
||||||
export const ContentGridView: React.FC<ContentGridViewProps> = ({
|
export const ContentGridView: React.FC<ContentGridViewProps> = ({
|
||||||
company,
|
companies,
|
||||||
pieces,
|
contentMesh,
|
||||||
pillars,
|
onContentMeshChange,
|
||||||
onPiecesChange,
|
|
||||||
onPillarsChange,
|
|
||||||
onOpenProject,
|
onOpenProject,
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('calendar');
|
const [viewMode, setViewMode] = useState<'calendar' | 'timeline'>('calendar');
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
const [editingPiece, setEditingPiece] = useState<ContentPiece | null>(null);
|
const [filterBrandId, setFilterBrandId] = useState<string>('');
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [createDate, setCreateDate] = useState<string | undefined>();
|
|
||||||
|
|
||||||
// Grid view state
|
const selectedFilterBrand = filterBrandId ? companies.find(c => c.id === filterBrandId) : null;
|
||||||
const [gridPlatform, setGridPlatform] = useState<Platform>('instagram');
|
const title = selectedFilterBrand ? `Malla de Contenidos - ${selectedFilterBrand.name}` : 'Malla de Contenidos Global';
|
||||||
|
|
||||||
// Filters
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const [selectedPillar, setSelectedPillar] = useState<string | null>(null);
|
const { active, over } = event;
|
||||||
const [selectedStatus, setSelectedStatus] = useState<ContentStatus | null>(null);
|
if (!over) return;
|
||||||
const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
|
|
||||||
// Filter pieces
|
if (active.data.current?.type === 'generated-media') {
|
||||||
const filteredPieces = useMemo(() => {
|
const { brandId, mediaItem } = active.data.current;
|
||||||
return pieces.filter(p => {
|
|
||||||
if (selectedPillar && p.pillarId !== selectedPillar) return false;
|
let targetDate = over.id as string;
|
||||||
if (selectedStatus && p.status !== selectedStatus) return false;
|
let targetStatus = 'draft'; // default status
|
||||||
if (selectedPlatform && !p.platforms.includes(selectedPlatform)) return false;
|
let targetTime = '12:00'; // default time
|
||||||
if (searchQuery) {
|
|
||||||
const q = searchQuery.toLowerCase();
|
if (String(over.id).startsWith('timeline-')) {
|
||||||
const matches =
|
// Dropped into a specific time slot
|
||||||
p.title.toLowerCase().includes(q) ||
|
if (!selectedDate) return;
|
||||||
(p.description || '').toLowerCase().includes(q) ||
|
targetDate = selectedDate;
|
||||||
(p.caption || '').toLowerCase().includes(q);
|
targetTime = String(over.id).replace('timeline-', '');
|
||||||
if (!matches) return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
});
|
// Simple validation for YYYY-MM-DD
|
||||||
}, [pieces, selectedPillar, selectedStatus, selectedPlatform, searchQuery]);
|
const dateParts = targetDate.split('-');
|
||||||
|
if (dateParts.length !== 3) return;
|
||||||
|
const [year, month, day] = dateParts;
|
||||||
|
|
||||||
// Stats
|
const newMesh = JSON.parse(JSON.stringify(contentMesh || {})); // deep copy
|
||||||
const stats = useMemo(() => {
|
|
||||||
const total = pieces.length;
|
|
||||||
const scheduled = pieces.filter(p => p.status === 'scheduled').length;
|
|
||||||
const published = pieces.filter(p => p.status === 'published').length;
|
|
||||||
const thisWeek = pieces.filter(p => {
|
|
||||||
if (!p.scheduledDate) return false;
|
|
||||||
const d = new Date(p.scheduledDate);
|
|
||||||
const now = new Date();
|
|
||||||
const weekEnd = new Date(now);
|
|
||||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
|
||||||
return d >= now && d <= weekEnd;
|
|
||||||
}).length;
|
|
||||||
return { total, scheduled, published, thisWeek };
|
|
||||||
}, [pieces]);
|
|
||||||
|
|
||||||
// Handlers
|
if (!newMesh[year]) newMesh[year] = {};
|
||||||
const handleCreatePiece = useCallback((date?: string) => {
|
if (!newMesh[year][month]) newMesh[year][month] = {};
|
||||||
setCreateDate(date);
|
if (!newMesh[year][month][day]) newMesh[year][month][day] = { images: [], videos: [] };
|
||||||
setEditingPiece(null);
|
|
||||||
setShowCreateModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSavePiece = useCallback((piece: ContentPiece) => {
|
const format = mediaItem.type === 'video' ? 'videos' : 'images';
|
||||||
piece.companyId = company.id;
|
|
||||||
const exists = pieces.find(p => p.id === piece.id);
|
newMesh[year][month][day][format].push({
|
||||||
if (exists) {
|
id: `mesh-${Date.now()}`,
|
||||||
onPiecesChange(pieces.map(p => p.id === piece.id ? piece : p));
|
mark_id: brandId,
|
||||||
} else {
|
file_path: mediaItem.path,
|
||||||
// Apply the pre-set date if creating from calendar
|
original_name: mediaItem.name || (mediaItem.path.split('/').pop() || mediaItem.path),
|
||||||
if (createDate && !piece.scheduledDate) {
|
status: targetStatus,
|
||||||
piece.scheduledDate = createDate;
|
time: targetTime,
|
||||||
if (piece.status === 'idea') piece.status = 'draft';
|
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 (
|
return (
|
||||||
<div className="flex-1 overflow-hidden flex flex-col w-full relative bg-neutral-950">
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
{/* Background pattern */}
|
<div className="flex-1 flex flex-col overflow-hidden w-full relative bg-neutral-950">
|
||||||
<div
|
{/* Background pattern */}
|
||||||
className="absolute inset-0 opacity-[0.02]"
|
<div
|
||||||
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
|
className="absolute inset-0 opacity-[0.02] pointer-events-none"
|
||||||
/>
|
style={{ backgroundImage: 'radial-gradient(circle at 1px 1px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col overflow-hidden p-6">
|
{/* Global Header (Full Width) */}
|
||||||
{/* ═══ Header ═══ */}
|
<div className="relative z-10 p-4 px-6 border-b border-neutral-800/50 bg-neutral-900/40 flex items-center justify-between shrink-0">
|
||||||
<div className="flex items-center justify-between mb-5">
|
{/* Left: Brand Selector */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-600 to-fuchsia-600 flex items-center justify-center shadow-lg shadow-violet-900/20">
|
<label className="text-sm font-medium text-neutral-400">Marca:</label>
|
||||||
<CalendarDays size={18} className="text-white" />
|
<select
|
||||||
</div>
|
value={filterBrandId}
|
||||||
<div>
|
onChange={(e) => setFilterBrandId(e.target.value)}
|
||||||
<h1 className="text-lg font-bold text-white tracking-tight">Malla de Contenidos</h1>
|
className="bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-2 text-sm text-white focus:border-violet-500 focus:outline-none transition-colors min-w-[240px]"
|
||||||
<p className="text-[11px] text-neutral-500">
|
>
|
||||||
{company.name} · {filteredPieces.length} de {pieces.length} piezas
|
<option value="">Todas las marcas</option>
|
||||||
</p>
|
{companies.map(c => (
|
||||||
</div>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Right: Title */}
|
||||||
{/* Stats mini-bar */}
|
<div className="text-right">
|
||||||
<div className="hidden md:flex items-center gap-3 mr-3">
|
<h1 className="text-xl font-bold text-white tracking-tight">{title}</h1>
|
||||||
<StatPill label="Esta semana" value={stats.thisWeek} icon={<TrendingUp size={10} />} color="#a78bfa" />
|
<p className="text-xs text-neutral-400 mt-1">Arrastra el contenido finalizado al calendario.</p>
|
||||||
<StatPill label="Programados" value={stats.scheduled} icon={<CalendarDays size={10} />} color="#60a5fa" />
|
|
||||||
<StatPill label="Publicados" value={stats.published} icon={<BarChart3 size={10} />} color="#22c55e" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSettings(!showSettings)}
|
|
||||||
className={`p-2 rounded-lg transition-all border ${
|
|
||||||
showSettings
|
|
||||||
? 'bg-violet-600/15 border-violet-500/30 text-violet-300'
|
|
||||||
: 'bg-neutral-900/60 border-neutral-800 text-neutral-500 hover:text-white hover:border-neutral-700'
|
|
||||||
}`}
|
|
||||||
title="Configurar Pilares"
|
|
||||||
>
|
|
||||||
<Settings2 size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* New content CTA */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleCreatePiece()}
|
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white rounded-xl text-xs font-semibold transition-all shadow-lg shadow-violet-900/20 hover:shadow-violet-900/40 hover:scale-[1.02] active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<Plus size={14} /> Nuevo Contenido
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══ Settings Panel (Pillar Manager) ═══ */}
|
{/* Content Area */}
|
||||||
{showSettings && (
|
<div className="flex-1 flex overflow-hidden relative z-10">
|
||||||
<div className="mb-5 bg-neutral-900/40 border border-neutral-800/50 rounded-xl p-4 animate-in fade-in-0 slide-in-from-top-2 duration-200">
|
{/* Sidebar */}
|
||||||
<PillarManager pillars={pillars} onChange={onPillarsChange} />
|
<ContentMeshSidebar companies={companies} filterBrandId={filterBrandId} />
|
||||||
|
|
||||||
|
{/* Main Area */}
|
||||||
|
<div className="flex-1 overflow-hidden p-6">
|
||||||
|
{viewMode === 'calendar' ? (
|
||||||
|
<CalendarView
|
||||||
|
contentMesh={contentMesh}
|
||||||
|
onContentMeshChange={onContentMeshChange}
|
||||||
|
companies={companies}
|
||||||
|
filterBrandId={filterBrandId}
|
||||||
|
onSelectDate={(date) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setViewMode('timeline');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : selectedDate ? (
|
||||||
|
<DailyTimelineView
|
||||||
|
dateKey={selectedDate}
|
||||||
|
contentMesh={contentMesh}
|
||||||
|
onContentMeshChange={onContentMeshChange}
|
||||||
|
companies={companies}
|
||||||
|
filterBrandId={filterBrandId}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedDate(null);
|
||||||
|
setViewMode('calendar');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══ View Mode Toggle + Filters ═══ */}
|
|
||||||
<div className="flex items-start justify-between gap-4 mb-5">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<ContentFilters
|
|
||||||
pillars={pillars}
|
|
||||||
selectedPillar={selectedPillar}
|
|
||||||
onPillarChange={setSelectedPillar}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
onStatusChange={setSelectedStatus}
|
|
||||||
selectedPlatform={selectedPlatform}
|
|
||||||
onPlatformChange={setSelectedPlatform}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View toggle */}
|
|
||||||
<div className="flex bg-neutral-900 border border-neutral-800 rounded-xl p-0.5 shrink-0">
|
|
||||||
{([
|
|
||||||
{ id: 'calendar' as ViewMode, icon: <CalendarDays size={14} />, label: 'Calendario' },
|
|
||||||
{ id: 'grid' as ViewMode, icon: <LayoutGrid size={14} />, label: 'Grid' },
|
|
||||||
{ id: 'list' as ViewMode, icon: <List size={14} />, label: 'Lista' },
|
|
||||||
]).map(v => (
|
|
||||||
<button
|
|
||||||
key={v.id}
|
|
||||||
onClick={() => setViewMode(v.id)}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
|
||||||
viewMode === v.id
|
|
||||||
? 'bg-neutral-800 text-white shadow-sm'
|
|
||||||
: 'text-neutral-500 hover:text-neutral-300'
|
|
||||||
}`}
|
|
||||||
title={v.label}
|
|
||||||
>
|
|
||||||
{v.icon}
|
|
||||||
<span className="hidden sm:inline">{v.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ═══ View Content ═══ */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{viewMode === 'calendar' && (
|
|
||||||
<CalendarView
|
|
||||||
pieces={filteredPieces}
|
|
||||||
pillars={pillars}
|
|
||||||
onPieceClick={handlePieceClick}
|
|
||||||
onCreatePiece={(date) => handleCreatePiece(date)}
|
|
||||||
onDropPiece={handleDropPiece}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{viewMode === 'grid' && (
|
|
||||||
<GridView
|
|
||||||
pieces={filteredPieces}
|
|
||||||
pillars={pillars}
|
|
||||||
onPieceClick={handlePieceClick}
|
|
||||||
platform={gridPlatform}
|
|
||||||
onPlatformChange={setGridPlatform}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{viewMode === 'list' && (
|
|
||||||
<ListView
|
|
||||||
pieces={filteredPieces}
|
|
||||||
pillars={pillars}
|
|
||||||
onPieceClick={handlePieceClick}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{filteredPieces.length === 0 && pieces.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-neutral-600">
|
|
||||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-violet-600/10 to-fuchsia-600/10 border border-violet-500/10 flex items-center justify-center mb-4">
|
|
||||||
<Sparkles size={28} className="text-violet-500/40" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-semibold text-neutral-400 mb-1">Tu malla está vacía</h3>
|
|
||||||
<p className="text-xs text-neutral-600 text-center max-w-xs mb-4">
|
|
||||||
Empieza a planificar tu contenido creando piezas y organizándolas en el calendario
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCreatePiece()}
|
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-violet-600/15 hover:bg-violet-600/25 text-violet-400 text-xs font-semibold rounded-xl border border-violet-500/20 hover:border-violet-500/40 transition-all"
|
|
||||||
>
|
|
||||||
<Plus size={14} /> Crear primera pieza
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DndContext>
|
||||||
{/* ═══ Content Detail Modal ═══ */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<ContentDetailModal
|
|
||||||
piece={editingPiece}
|
|
||||||
pillars={pillars}
|
|
||||||
projects={company.projects || []}
|
|
||||||
onSave={handleSavePiece}
|
|
||||||
onDelete={handleDeletePiece}
|
|
||||||
onClose={() => { setShowCreateModal(false); setEditingPiece(null); setCreateDate(undefined); }}
|
|
||||||
onOpenProject={onOpenProject}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mini stat pill for the header */
|
|
||||||
const StatPill: React.FC<{ label: string; value: number; icon: React.ReactNode; color: string }> = ({
|
|
||||||
label, value, icon, color,
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-lg border text-[10px] font-medium"
|
|
||||||
style={{ borderColor: `${color}20`, color, backgroundColor: `${color}08` }}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span className="font-bold">{value}</span>
|
|
||||||
<span className="opacity-60">{label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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<ContentMeshSidebarProps> = ({ companies, filterBrandId }) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-80 border-r border-neutral-800 bg-neutral-900/60 flex flex-col h-full overflow-hidden shrink-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-neutral-800">
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-4">Contenido Generado</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3"> {/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Search size={14} className="text-neutral-500" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||||
|
<GeneratedMediaList brandId={filterBrandId} companies={companies} searchQuery={searchQuery} draggable={true} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<DailyTimelineViewProps> = ({
|
||||||
|
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<HTMLDivElement>(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<string, number> = {};
|
||||||
|
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 (
|
||||||
|
<div className="h-full flex flex-col overflow-hidden animate-in fade-in bg-neutral-900 border border-neutral-800 rounded-2xl shadow-xl relative">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-neutral-800 flex items-center justify-between bg-neutral-900/90 backdrop-blur shrink-0 z-20 absolute top-0 left-0 right-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors bg-neutral-950 border border-neutral-800 shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-8 bg-neutral-800 hidden sm:block"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{selectedBrand && selectedBrand.logo && (
|
||||||
|
<img src={selectedBrand.logo} alt={selectedBrand.name} className="w-8 h-8 rounded object-cover" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white tracking-tight">
|
||||||
|
{capitalizedDate} {selectedBrand ? `· ${selectedBrand.name}` : ''}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-xs text-neutral-400 font-medium">{totalPosts} publicaciones programadas</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{Object.entries(platformCounts).map(([plat, count]) => {
|
||||||
|
const pConfig = PLATFORMS.find(p => p.id === plat);
|
||||||
|
if (!pConfig) return null;
|
||||||
|
const Icon = pConfig.icon;
|
||||||
|
return (
|
||||||
|
<div key={plat} className={`flex items-center gap-1 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${pConfig.bg} ${pConfig.color}`} title={plat}>
|
||||||
|
<Icon size={10} /> {count}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Scroll Area */}
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto custom-scrollbar pt-[80px] bg-neutral-950/50">
|
||||||
|
<div className="relative min-w-[600px] mx-auto max-w-4xl px-4 pb-12" style={{ height: `${(24 - startHour) * SLOT_HEIGHT + 60}px` }}>
|
||||||
|
|
||||||
|
{/* Collapsed Morning Bar */}
|
||||||
|
<div className="mb-4 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMorningCollapsed(!isMorningCollapsed)}
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-xl bg-neutral-900 border border-neutral-800 text-neutral-400 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Moon size={16} className="text-indigo-400" />
|
||||||
|
12 AM – 7 AM · <span className="text-neutral-500 font-normal">Oculto por defecto</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
{isMorningCollapsed ? 'Expandir' : 'Colapsar'}
|
||||||
|
{isMorningCollapsed ? <ChevronDown size={14} /> : <ChevronLeft size={14} className="rotate-90" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative" style={{ height: `${(24 - startHour) * SLOT_HEIGHT}px` }}>
|
||||||
|
{/* Time axis background lines */}
|
||||||
|
{HOURS.slice(startHour).map(hour => (
|
||||||
|
<div key={hour} className="absolute w-full border-t border-neutral-800/40 pointer-events-none" style={{ top: `${(hour - startHour) * SLOT_HEIGHT}px` }}>
|
||||||
|
<div className="absolute -top-[9px] left-0 text-[11px] font-medium text-neutral-500 bg-neutral-950/80 px-1">
|
||||||
|
{hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Droppable Slots (every 30 mins) */}
|
||||||
|
<div className="absolute top-0 bottom-0 left-[60px] right-0 flex flex-col z-0">
|
||||||
|
{HOURS.slice(startHour).map(hour => (
|
||||||
|
<React.Fragment key={`slots-${hour}`}>
|
||||||
|
<TimeSlot timeStr={`${hour.toString().padStart(2, '0')}:00`} height={HALF_SLOT} />
|
||||||
|
<TimeSlot timeStr={`${hour.toString().padStart(2, '0')}:30`} height={HALF_SLOT} />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Draggable Items */}
|
||||||
|
<div className="absolute top-0 bottom-0 left-[70px] right-2 z-10 pointer-events-none">
|
||||||
|
{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 (
|
||||||
|
<TimelineItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
brandName={getBrandName(item.mark_id)}
|
||||||
|
getUrl={getUrl}
|
||||||
|
onOpenFolder={handleOpenPath}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
onTogglePlatform={handleTogglePlatform}
|
||||||
|
startHour={startHour}
|
||||||
|
overlapOffset={index}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`w-full border-b border-dashed relative transition-colors ${isOver ? 'bg-violet-500/10' : ''} ${isPeak ? 'border-violet-500/40 bg-violet-500/5' : 'border-neutral-800/20'}`}
|
||||||
|
style={{ height: `${height}px` }}
|
||||||
|
>
|
||||||
|
{isPeak && !isOver && (
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-bold text-violet-400/80 flex items-center gap-1 bg-violet-500/10 px-2 py-0.5 rounded-full pointer-events-none">
|
||||||
|
🔥 Buena hora
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOver && (
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-bold text-violet-300 pointer-events-none">
|
||||||
|
Suelta el contenido aquí
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`absolute left-0 pointer-events-auto flex items-stretch bg-neutral-900 border ${isPosted ? 'border-neutral-800' : 'border-neutral-700'} rounded-xl overflow-hidden transition-shadow shadow-lg ${isDragging ? 'shadow-2xl shadow-violet-900/30 ring-2 ring-violet-500' : ''} hover:border-neutral-500 w-[340px] h-[64px]`}
|
||||||
|
>
|
||||||
|
{/* Thumbnail Area - acts as drag handle */}
|
||||||
|
<div
|
||||||
|
className="w-[64px] h-full bg-neutral-950 relative flex items-center justify-center shrink-0 border-r border-neutral-800 cursor-grab active:cursor-grabbing group"
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{isVideo ? (
|
||||||
|
<video
|
||||||
|
src={fileUrl}
|
||||||
|
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
onMouseEnter={(e) => e.currentTarget.play()}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.pause();
|
||||||
|
e.currentTarget.currentTime = 0;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={fileUrl}
|
||||||
|
alt={item.original_name}
|
||||||
|
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Area */}
|
||||||
|
<div className="flex-1 min-w-0 p-2.5 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold text-violet-400">{item.time || '12:00'}</span>
|
||||||
|
{/* Platform toggles inline */}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{PLATFORMS.map(p => {
|
||||||
|
const Icon = p.icon;
|
||||||
|
const isActive = currentPlatforms.includes(p.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onTogglePlatform(item.id, p.id)}
|
||||||
|
className={`p-1 rounded transition-colors ${isActive ? p.bg + ' ' + p.color : 'text-neutral-600 hover:bg-neutral-800 hover:text-neutral-400'}`}
|
||||||
|
title={p.id}
|
||||||
|
>
|
||||||
|
<Icon size={10} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleStatus(item.id, currentStatus)}
|
||||||
|
className={`px-2 py-0.5 text-[9px] font-bold border rounded-full transition-colors shrink-0 ${statusUI.color}`}
|
||||||
|
>
|
||||||
|
{statusUI.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenFolder(item.file_path)}
|
||||||
|
className="p-1 rounded bg-neutral-800 text-neutral-400 hover:text-white transition-colors shrink-0"
|
||||||
|
title="Abrir en Finder"
|
||||||
|
>
|
||||||
|
<FolderOpen size={10} />
|
||||||
|
</button>
|
||||||
|
<h4 className={`text-xs font-medium truncate ${isPosted ? 'text-neutral-500 line-through' : 'text-white'}`} title={item.original_name}>
|
||||||
|
{item.original_name}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<void>;
|
||||||
|
}> = ({ 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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`group relative bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-violet-500/50 transition-colors ${isDragging ? 'shadow-2xl shadow-violet-500/20' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Thumbnail Area - acts as drag handle if draggable */}
|
||||||
|
<div
|
||||||
|
className={`aspect-video bg-neutral-950 relative flex items-center justify-center overflow-hidden ${draggable && !isEditing ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||||
|
{...(draggable && !isEditing ? listeners : {})}
|
||||||
|
{...(draggable && !isEditing ? attributes : {})}
|
||||||
|
>
|
||||||
|
{item.type === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={getUrl(item.path)}
|
||||||
|
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
onMouseEnter={(e) => e.currentTarget.play()}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.pause();
|
||||||
|
e.currentTarget.currentTime = 0;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={getUrl(item.path)}
|
||||||
|
alt={displayName}
|
||||||
|
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 bg-black/60 rounded text-[10px] font-medium text-white backdrop-blur-sm uppercase">
|
||||||
|
{item.type}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Area */}
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={e => 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}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSaveRename} disabled={isSaving} className="text-green-500 hover:bg-green-500/20 p-1 rounded">
|
||||||
|
<Check size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditName(displayName); setIsEditing(false); }} disabled={isSaving} className="text-red-500 hover:bg-red-500/20 p-1 rounded">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 group/edit">
|
||||||
|
<p className="text-sm font-medium text-white truncate flex-1" title={displayName}>
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="opacity-0 group-hover/edit:opacity-100 p-1 text-neutral-400 hover:text-white transition-opacity"
|
||||||
|
title="Renombrar"
|
||||||
|
>
|
||||||
|
<Edit2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">
|
||||||
|
{new Date(item.date).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenFolder(item.path)}
|
||||||
|
title="Abrir en Finder"
|
||||||
|
className="shrink-0 p-1.5 text-neutral-400 hover:text-white hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GeneratedMediaList: React.FC<GeneratedMediaListProps> = ({ brandId, companies, searchQuery, draggable = false }) => {
|
||||||
|
const [media, setMedia] = useState<MediaItem[]>([]);
|
||||||
|
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 <div className="text-neutral-500 text-sm p-4">Cargando...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||||
|
No se encontró contenido.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid gap-4 ${draggable ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||||
|
{filteredMedia.map((item, idx) => (
|
||||||
|
<DraggableMediaCard
|
||||||
|
key={`${item.path}-${idx}`}
|
||||||
|
item={item}
|
||||||
|
brandId={brandId}
|
||||||
|
draggable={draggable}
|
||||||
|
getUrl={getUrl}
|
||||||
|
onOpenFolder={handleOpenPath}
|
||||||
|
onRename={handleRename}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,9 +21,11 @@ interface BatchDataPanelProps {
|
|||||||
templateFormat: 'video' | 'image';
|
templateFormat: 'video' | 'image';
|
||||||
onSetBackgrounds: (files: File[]) => void;
|
onSetBackgrounds: (files: File[]) => void;
|
||||||
onUpdateField: (index: number, fieldId: string, value: string) => void;
|
onUpdateField: (index: number, fieldId: string, value: string) => void;
|
||||||
|
onUpdateVariation: (index: number, variationId: string | null) => void;
|
||||||
onImportCSV: (file: File) => Promise<{ matched: number; unmatched: number }>;
|
onImportCSV: (file: File) => Promise<{ matched: number; unmatched: number }>;
|
||||||
onRemovePiece: (index: number) => void;
|
onRemovePiece: (index: number) => void;
|
||||||
backgroundFiles: File[];
|
backgroundFiles: File[];
|
||||||
|
availableVariations: { id: string; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get only text-type editable slots (for table columns) */
|
/** Get only text-type editable slots (for table columns) */
|
||||||
@@ -38,9 +40,11 @@ export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
|
|||||||
templateFormat,
|
templateFormat,
|
||||||
onSetBackgrounds,
|
onSetBackgrounds,
|
||||||
onUpdateField,
|
onUpdateField,
|
||||||
|
onUpdateVariation,
|
||||||
onImportCSV,
|
onImportCSV,
|
||||||
onRemovePiece,
|
onRemovePiece,
|
||||||
backgroundFiles,
|
backgroundFiles,
|
||||||
|
availableVariations,
|
||||||
}) => {
|
}) => {
|
||||||
const bgInputRef = useRef<HTMLInputElement>(null);
|
const bgInputRef = useRef<HTMLInputElement>(null);
|
||||||
const csvInputRef = useRef<HTMLInputElement>(null);
|
const csvInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -145,7 +149,7 @@ export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Text Data Table ── */}
|
{/* ── Text Data Table ── */}
|
||||||
{textSlots.length > 0 && N > 0 && (
|
{(textSlots.length > 0 || availableVariations.length > 0) && N > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Table header with CSV import */}
|
{/* Table header with CSV import */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -185,11 +189,14 @@ export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="grid gap-px bg-neutral-800/50 text-[9px] text-neutral-500 font-bold uppercase tracking-wider"
|
className="grid gap-px bg-neutral-800/50 text-[9px] text-neutral-500 font-bold uppercase tracking-wider"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `36px 90px ${textSlots.map(() => '1fr').join(' ')} 28px`,
|
gridTemplateColumns: `36px 90px ${availableVariations.length > 0 ? '100px ' : ''}${textSlots.map(() => '1fr').join(' ')} 28px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-neutral-900/80 px-2 py-1.5 text-center">#</div>
|
<div className="bg-neutral-900/80 px-2 py-1.5 text-center">#</div>
|
||||||
<div className="bg-neutral-900/80 px-2 py-1.5">Fondo</div>
|
<div className="bg-neutral-900/80 px-2 py-1.5">Fondo</div>
|
||||||
|
{availableVariations.length > 0 && (
|
||||||
|
<div className="bg-neutral-900/80 px-2 py-1.5">Variación</div>
|
||||||
|
)}
|
||||||
{textSlots.map(({ field }) => (
|
{textSlots.map(({ field }) => (
|
||||||
<div key={field.id} className="bg-neutral-900/80 px-2 py-1.5 truncate">
|
<div key={field.id} className="bg-neutral-900/80 px-2 py-1.5 truncate">
|
||||||
{field.label}
|
{field.label}
|
||||||
@@ -208,7 +215,7 @@ export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
|
|||||||
hasErrors ? 'bg-red-500/5' : 'bg-neutral-800/20'
|
hasErrors ? 'bg-red-500/5' : 'bg-neutral-800/20'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `36px 90px ${textSlots.map(() => '1fr').join(' ')} 28px`,
|
gridTemplateColumns: `36px 90px ${availableVariations.length > 0 ? '100px ' : ''}${textSlots.map(() => '1fr').join(' ')} 28px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Row number */}
|
{/* Row number */}
|
||||||
@@ -223,6 +230,22 @@ export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Variation Selector */}
|
||||||
|
{availableVariations.length > 0 && (
|
||||||
|
<div className="bg-neutral-900/60 px-0.5 py-0.5 flex items-center">
|
||||||
|
<select
|
||||||
|
value={piece.variationId || ''}
|
||||||
|
onChange={(e) => onUpdateVariation(piece.index, e.target.value || null)}
|
||||||
|
className="w-full bg-transparent px-1.5 py-1 rounded text-[10px] text-neutral-300 focus:outline-none focus:bg-neutral-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Layout Default</option>
|
||||||
|
{availableVariations.map(v => (
|
||||||
|
<option key={v.id} value={v.id}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Text fields */}
|
{/* Text fields */}
|
||||||
{textSlots.map(({ field }) => {
|
{textSlots.map(({ field }) => {
|
||||||
const val = piece.fieldData[field.id] || '';
|
const val = piece.fieldData[field.id] || '';
|
||||||
@@ -279,8 +302,8 @@ export const BatchDataPanel: React.FC<BatchDataPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Empty state (no text fields) ── */}
|
{/* ── Empty state (no text fields and no variations) ── */}
|
||||||
{textSlots.length === 0 && N > 0 && (
|
{textSlots.length === 0 && availableVariations.length === 0 && N > 0 && (
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<p className="text-[10px] text-neutral-500">
|
<p className="text-[10px] text-neutral-500">
|
||||||
Esta plantilla no tiene campos de texto editables.
|
Esta plantilla no tiene campos de texto editables.
|
||||||
|
|||||||
@@ -28,56 +28,66 @@ export const GenerateZone: React.FC<GenerateZoneProps> = ({
|
|||||||
const canGenerate = !!selectedTemplate && !!selectedBrand;
|
const canGenerate = !!selectedTemplate && !!selectedBrand;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-neutral-900/50 border border-neutral-800/50 rounded-2xl p-5">
|
<div className="bg-neutral-900/50 border border-neutral-800/50 rounded-2xl p-6 flex items-center justify-between gap-8">
|
||||||
{/* Header */}
|
{/* Left: Info */}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex flex-col gap-1.5 shrink-0 min-w-[220px]">
|
||||||
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-violet-600/20 to-fuchsia-600/20 flex items-center justify-center">
|
<div className="flex items-center gap-3">
|
||||||
<Sparkles size={14} className="text-violet-400" />
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-600/20 to-fuchsia-600/20 flex items-center justify-center border border-violet-500/20 shadow-inner">
|
||||||
|
<Sparkles size={18} className="text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-white">Generar contenido</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">
|
||||||
|
Arrastra una plantilla y una marca
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-sm font-bold text-white">Generar contenido</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-neutral-500 mb-5 ml-8">
|
|
||||||
Arrastra una plantilla y una marca, o toca para elegir.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Slots row */}
|
{/* Middle: Slots row */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-center gap-6 flex-1 max-w-[800px]">
|
||||||
{/* Template slot */}
|
{/* Template slot */}
|
||||||
<DropSlot
|
<div className="flex-1 max-w-[320px]">
|
||||||
type="template"
|
<DropSlot
|
||||||
item={selectedTemplate}
|
type="template"
|
||||||
onClear={onClearTemplate}
|
item={selectedTemplate}
|
||||||
onClick={onClickTemplateSlot}
|
onClear={onClearTemplate}
|
||||||
/>
|
onClick={onClickTemplateSlot}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* × separator */}
|
{/* × separator */}
|
||||||
<div className="shrink-0 flex items-center justify-center">
|
<div className="shrink-0 flex items-center justify-center">
|
||||||
<span className="text-xl font-bold text-neutral-600 select-none">×</span>
|
<span className="text-2xl font-bold text-neutral-700 select-none">×</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Brand slot */}
|
{/* Brand slot */}
|
||||||
<DropSlot
|
<div className="flex-1 max-w-[320px]">
|
||||||
type="brand"
|
<DropSlot
|
||||||
item={selectedBrand}
|
type="brand"
|
||||||
onClear={onClearBrand}
|
item={selectedBrand}
|
||||||
onClick={onClickBrandSlot}
|
onClear={onClearBrand}
|
||||||
/>
|
onClick={onClickBrandSlot}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Generate button */}
|
{/* Right: Generate button */}
|
||||||
|
<div className="shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={!canGenerate}
|
disabled={!canGenerate}
|
||||||
title={canGenerate ? 'Generar contenido con esta plantilla y marca' : 'Selecciona una plantilla y una marca primero'}
|
title={canGenerate ? 'Generar contenido con esta plantilla y marca' : 'Selecciona una plantilla y una marca primero'}
|
||||||
className={`
|
className={`
|
||||||
shrink-0 flex items-center gap-2 px-6 py-4 rounded-xl font-bold text-sm transition-all duration-200
|
flex items-center justify-center gap-3 px-8 py-5 rounded-xl font-bold text-base transition-all duration-300 min-w-[200px]
|
||||||
${canGenerate
|
${canGenerate
|
||||||
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-lg shadow-violet-900/30 hover:shadow-violet-900/50 hover:scale-[1.02] active:scale-[0.98]'
|
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-xl shadow-violet-900/40 hover:shadow-violet-900/60 hover:scale-[1.03] active:scale-[0.98]'
|
||||||
: 'bg-neutral-800/50 text-neutral-600 cursor-not-allowed border border-neutral-800'
|
: 'bg-neutral-800/50 text-neutral-600 cursor-not-allowed border border-neutral-800/80'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
Generar
|
Generar
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { migrateExpressFields } from '../../context/TemplateBuilderContext';
|
|||||||
import { useBatchProduction } from '../../hooks/useBatchProduction';
|
import { useBatchProduction } from '../../hooks/useBatchProduction';
|
||||||
import { useVideoDurations } from '../../hooks/useVideoDurations';
|
import { useVideoDurations } from '../../hooks/useVideoDurations';
|
||||||
import { BatchDataPanel } from './BatchDataPanel';
|
import { BatchDataPanel } from './BatchDataPanel';
|
||||||
import { exportBatchAsZip, BatchExportProgress } from '../../utils/batchExporter';
|
import { exportBatchToDisk, BatchExportProgress } from '../../utils/batchExporter';
|
||||||
|
|
||||||
interface ProductionFormProps {
|
interface ProductionFormProps {
|
||||||
template: ExpressTemplate;
|
template: ExpressTemplate;
|
||||||
@@ -111,11 +111,23 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
const totalDuration = getTemplateDuration(template, videoDurations, designMD);
|
const totalDuration = getTemplateDuration(template, videoDurations, designMD);
|
||||||
const totalFrames = Math.max(30, totalDuration * fps);
|
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<string | null>(null);
|
||||||
|
|
||||||
// ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ───
|
// ─── Compile for ExportModal (only when modal is open — LivePreviewCanvas handles its own compile) ───
|
||||||
const compiled = useMemo(
|
const compiled = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (!showExportModal) return { elements: [], layers: [] };
|
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 => {
|
result.elements = result.elements.map(el => {
|
||||||
const fieldId = el.sourceFieldId;
|
const fieldId = el.sourceFieldId;
|
||||||
const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
|
const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
|
||||||
@@ -130,7 +142,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
});
|
});
|
||||||
return result;
|
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 ───
|
// ─── Collect all TemplateFields across all scenes ───
|
||||||
@@ -251,7 +263,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' });
|
setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await exportBatchAsZip(
|
await exportBatchToDisk(
|
||||||
batch.pieces,
|
batch.pieces,
|
||||||
template,
|
template,
|
||||||
brand,
|
brand,
|
||||||
@@ -277,7 +289,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Compile to timeline elements
|
// 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
|
// 3. Apply fit overrides
|
||||||
compiled.elements = compiled.elements.map(el => {
|
compiled.elements = compiled.elements.map(el => {
|
||||||
@@ -331,6 +343,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
layers: compiled.layers,
|
layers: compiled.layers,
|
||||||
brandVisibility: { logo: false, frame: false, background: true },
|
brandVisibility: { logo: false, frame: false, background: true },
|
||||||
outputFormat: 'video',
|
outputFormat: 'video',
|
||||||
|
brandId: brand.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [batch.pieces, template, backgroundFieldId, designMD, brand, videoDurations, mediaFits, containBgColors, startExport]);
|
}, [batch.pieces, template, backgroundFieldId, designMD, brand, videoDurations, mediaFits, containBgColors, startExport]);
|
||||||
@@ -412,9 +425,11 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
templateFormat={template.format}
|
templateFormat={template.format}
|
||||||
onSetBackgrounds={batch.setBackgroundFiles}
|
onSetBackgrounds={batch.setBackgroundFiles}
|
||||||
onUpdateField={batch.updatePieceField}
|
onUpdateField={batch.updatePieceField}
|
||||||
|
onUpdateVariation={batch.updatePieceVariation}
|
||||||
onImportCSV={batch.importCSV}
|
onImportCSV={batch.importCSV}
|
||||||
onRemovePiece={batch.removePiece}
|
onRemovePiece={batch.removePiece}
|
||||||
backgroundFiles={batch.backgroundFiles}
|
backgroundFiles={batch.backgroundFiles}
|
||||||
|
availableVariations={availableVariations}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* ── SINGLE MODE: Original form ── */
|
/* ── SINGLE MODE: Original form ── */
|
||||||
@@ -433,6 +448,23 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Variation selector */}
|
||||||
|
{availableVariations.length > 0 && (
|
||||||
|
<div className="px-5 pt-3 border-b border-neutral-800/30 shrink-0">
|
||||||
|
<label className="text-[10px] text-neutral-400 font-semibold mb-1 block">Variación de Diseño</label>
|
||||||
|
<select
|
||||||
|
value={activeVariationId || ''}
|
||||||
|
onChange={(e) => setActiveVariationId(e.target.value || null)}
|
||||||
|
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-violet-500/50 focus:outline-none mb-3"
|
||||||
|
>
|
||||||
|
<option value="">Layout Default</option>
|
||||||
|
{availableVariations.map(v => (
|
||||||
|
<option key={v.id} value={v.id}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scrollable fields */}
|
{/* Scrollable fields */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
|
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-5">
|
||||||
{/* ── Segment upload fields (form-sourced intro/outro) ── */}
|
{/* ── Segment upload fields (form-sourced intro/outro) ── */}
|
||||||
@@ -686,6 +718,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
onSceneChange={setActiveSceneId}
|
onSceneChange={setActiveSceneId}
|
||||||
playerRef={playerRef}
|
playerRef={playerRef}
|
||||||
videoDurations={videoDurations}
|
videoDurations={videoDurations}
|
||||||
|
variationId={batch.isBatchMode ? (batch.pieces[activeBatchPieceIndex]?.variationId || undefined) : (activeVariationId || undefined)}
|
||||||
statusLabel={
|
statusLabel={
|
||||||
batch.isBatchMode
|
batch.isBatchMode
|
||||||
? (batch.pieceCount > 0 ? `Pieza ${activeBatchPieceIndex + 1} de ${batch.pieceCount}` : 'Sin piezas')
|
? (batch.pieceCount > 0 ? `Pieza ${activeBatchPieceIndex + 1} de ${batch.pieceCount}` : 'Sin piezas')
|
||||||
@@ -738,6 +771,7 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
brandVisibility={{ logo: false, frame: false, background: true }}
|
brandVisibility={{ logo: false, frame: false, background: true }}
|
||||||
outputFormat={template.format}
|
outputFormat={template.format}
|
||||||
aspectRatio={template.aspectRatio}
|
aspectRatio={template.aspectRatio}
|
||||||
|
brandId={brand.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══ Batch Export Modal (video batch only) ═══ */}
|
{/* ═══ Batch Export Modal (video batch only) ═══ */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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';
|
import type { RenderJobClient } from '../../hooks/useExportQueue';
|
||||||
|
|
||||||
interface ExportJobItemProps {
|
interface ExportJobItemProps {
|
||||||
@@ -49,10 +49,10 @@ export const ExportJobItem: React.FC<ExportJobItemProps> = ({ job, onCancel, onD
|
|||||||
{job.status === 'done' && (
|
{job.status === 'done' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onDownload(job)}
|
onClick={() => onDownload(job)}
|
||||||
title="Descargar"
|
title="Abrir en carpeta"
|
||||||
className="p-1 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
className="p-1 rounded-md bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||||
>
|
>
|
||||||
<Download size={12} />
|
<FolderOpen size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(job.status === 'queued' || job.status === 'rendering') && (
|
{(job.status === 'queued' || job.status === 'rendering') && (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface ExportModalProps {
|
|||||||
brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
|
brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
|
||||||
outputFormat?: 'video' | 'image';
|
outputFormat?: 'video' | 'image';
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
|
brandId?: string;
|
||||||
onAssetSaved?: (url: string) => void;
|
onAssetSaved?: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
|
|||||||
brandVisibility,
|
brandVisibility,
|
||||||
outputFormat,
|
outputFormat,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
brandId,
|
||||||
onAssetSaved,
|
onAssetSaved,
|
||||||
}) => {
|
}) => {
|
||||||
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
|
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
|
||||||
@@ -162,6 +164,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
|
|||||||
layers,
|
layers,
|
||||||
brandVisibility,
|
brandVisibility,
|
||||||
outputFormat,
|
outputFormat,
|
||||||
|
brandId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const job = await startExport(config, {
|
const job = await startExport(config, {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useExportQueue } from '../../context/ExportQueueContext';
|
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 = () => {
|
export const GlobalExportWidget: React.FC = () => {
|
||||||
const { jobs, activeJobs, hasActiveJobs, downloadJob, cancelJob } = useExportQueue();
|
const { jobs, activeJobs, hasActiveJobs, downloadJob, cancelJob } = useExportQueue();
|
||||||
@@ -77,7 +77,7 @@ export const GlobalExportWidget: React.FC = () => {
|
|||||||
onClick={() => downloadJob(job)}
|
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"
|
className="flex items-center gap-1 text-[10px] bg-violet-600 hover:bg-violet-500 text-white px-2 py-1 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<Download size={12} /> Descargar
|
<FolderOpen size={12} /> Abrir
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export const BuilderCanvas: React.FC = () => {
|
|||||||
activeScene,
|
activeScene,
|
||||||
updateSegment,
|
updateSegment,
|
||||||
previewBrand,
|
previewBrand,
|
||||||
|
activeVariationId,
|
||||||
|
resolveFieldPosition,
|
||||||
} = useTemplateBuilder();
|
} = useTemplateBuilder();
|
||||||
|
|
||||||
// Detect segment mode: active scene is an intro/outro with segmentSource
|
// 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) => {
|
onMove: useCallback((id: string, x: number, y: number) => {
|
||||||
const field = fields.find(f => f.id === id);
|
const field = fields.find(f => f.id === id);
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
updateField(id, { position: { ...field.position, x, y } });
|
const pos = resolveFieldPosition(field);
|
||||||
}, [fields, updateField]),
|
updateField(id, { position: { ...pos, x, y } });
|
||||||
|
}, [fields, updateField, resolveFieldPosition]),
|
||||||
onResize: useCallback((id: string, w: number, h: number) => {
|
onResize: useCallback((id: string, w: number, h: number) => {
|
||||||
const field = fields.find(f => f.id === id);
|
const field = fields.find(f => f.id === id);
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
updateField(id, { position: { ...field.position, w, h } });
|
const pos = resolveFieldPosition(field);
|
||||||
}, [fields, updateField]),
|
updateField(id, { position: { ...pos, w, h } });
|
||||||
|
}, [fields, updateField, resolveFieldPosition]),
|
||||||
snapLines: [50],
|
snapLines: [50],
|
||||||
snapThreshold: 1.5,
|
snapThreshold: 1.5,
|
||||||
});
|
});
|
||||||
@@ -208,17 +212,18 @@ export const BuilderCanvas: React.FC = () => {
|
|||||||
const isDraggingField = dragFieldId === field.id;
|
const isDraggingField = dragFieldId === field.id;
|
||||||
const isLocked = field.locked === true;
|
const isLocked = field.locked === true;
|
||||||
const colors = NATURE_COLORS[field.nature];
|
const colors = NATURE_COLORS[field.nature];
|
||||||
|
const pos = resolveFieldPosition(field);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={field.id}
|
||||||
className="absolute transition-shadow"
|
className="absolute transition-shadow"
|
||||||
style={{
|
style={{
|
||||||
left: `${field.position.x - field.position.w / 2}%`,
|
left: `${pos.x - pos.w / 2}%`,
|
||||||
top: `${field.position.y - field.position.h / 2}%`,
|
top: `${pos.y - pos.h / 2}%`,
|
||||||
width: `${field.position.w}%`,
|
width: `${pos.w}%`,
|
||||||
height: `${field.position.h}%`,
|
height: `${pos.h}%`,
|
||||||
transform: field.position.rotation ? `rotate(${field.position.rotation}deg)` : undefined,
|
transform: pos.rotation ? `rotate(${pos.rotation}deg)` : undefined,
|
||||||
// z-index from array position: index 0 = back, last = front
|
// z-index from array position: index 0 = back, last = front
|
||||||
// Dragging/selected get temporary boost to stay on top during interaction
|
// Dragging/selected get temporary boost to stay on top during interaction
|
||||||
zIndex: isDraggingField ? 1000 : isSelected ? 999 : idx + 1,
|
zIndex: isDraggingField ? 1000 : isSelected ? 999 : idx + 1,
|
||||||
@@ -242,7 +247,7 @@ export const BuilderCanvas: React.FC = () => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (isLocked) return; // Can't interact with locked layers
|
if (isLocked) return; // Can't interact with locked layers
|
||||||
setSelectedFieldId(field.id);
|
setSelectedFieldId(field.id);
|
||||||
startDrag(e, field.id, field.position);
|
startDrag(e, field.id, pos);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Nature-specific content ── */}
|
{/* ── Nature-specific content ── */}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const FieldConfigPanel: React.FC = () => {
|
|||||||
editableSlotCount,
|
editableSlotCount,
|
||||||
totalFieldCount,
|
totalFieldCount,
|
||||||
templateMeta,
|
templateMeta,
|
||||||
|
resolveFieldPosition,
|
||||||
} = useTemplateBuilder();
|
} = useTemplateBuilder();
|
||||||
|
|
||||||
const field = fields.find(f => f.id === selectedFieldId);
|
const field = fields.find(f => f.id === selectedFieldId);
|
||||||
@@ -368,10 +369,10 @@ export const FieldConfigPanel: React.FC = () => {
|
|||||||
|
|
||||||
{/* ── Position (FieldInspector) ── */}
|
{/* ── Position (FieldInspector) ── */}
|
||||||
<FieldInspector
|
<FieldInspector
|
||||||
position={field.position}
|
position={resolveFieldPosition(field)}
|
||||||
onPositionChange={(pos) => {
|
onPositionChange={(pos) => {
|
||||||
updateField(field.id, {
|
updateField(field.id, {
|
||||||
position: { ...field.position, ...pos },
|
position: { ...resolveFieldPosition(field), ...pos },
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
textStyle={field.type === 'text' ? {
|
textStyle={field.type === 'text' ? {
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ const TemplateBuilderInner: React.FC<InnerProps> = ({
|
|||||||
updateSegment,
|
updateSegment,
|
||||||
introScene,
|
introScene,
|
||||||
outroScene,
|
outroScene,
|
||||||
|
// Variations
|
||||||
|
activeVariationId,
|
||||||
|
setActiveVariationId,
|
||||||
|
addVariation,
|
||||||
|
deleteVariation,
|
||||||
} = useTemplateBuilder();
|
} = useTemplateBuilder();
|
||||||
|
|
||||||
const sceneFieldsMap = useSceneFieldsMap();
|
const sceneFieldsMap = useSceneFieldsMap();
|
||||||
@@ -339,6 +344,44 @@ const TemplateBuilderInner: React.FC<InnerProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Variation Selector (design mode only) */}
|
||||||
|
{viewMode === 'design' && (
|
||||||
|
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5 relative group">
|
||||||
|
<select
|
||||||
|
value={activeVariationId || ''}
|
||||||
|
onChange={(e) => setActiveVariationId(e.target.value || null)}
|
||||||
|
className="bg-transparent text-[9px] font-medium text-white border-none focus:outline-none cursor-pointer px-2 py-1 appearance-none pr-4"
|
||||||
|
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 4px center' }}
|
||||||
|
>
|
||||||
|
<option value="">Layout Default</option>
|
||||||
|
{(activeScene?.variations || []).map(v => (
|
||||||
|
<option key={v.id} value={v.id}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const count = (activeScene?.variations || []).length + 1;
|
||||||
|
addVariation(`Variación ${count}`);
|
||||||
|
}}
|
||||||
|
title="Crear nueva variación"
|
||||||
|
className="px-1.5 py-0.5 hover:bg-neutral-700 rounded text-neutral-400 hover:text-white transition-colors text-[10px] font-bold border-l border-neutral-700/50"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{activeVariationId && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('¿Eliminar esta variación?')) deleteVariation(activeVariationId);
|
||||||
|
}}
|
||||||
|
title="Eliminar variación"
|
||||||
|
className="px-1.5 py-0.5 hover:bg-red-500/20 hover:text-red-400 rounded text-neutral-400 transition-colors text-[9px] font-bold border-l border-neutral-700/50"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Brand preview selector */}
|
{/* Brand preview selector */}
|
||||||
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
|
<div className="flex items-center bg-neutral-800/60 rounded-lg border border-neutral-700/50 p-0.5">
|
||||||
<Briefcase size={10} className={previewBrand ? 'text-violet-400 ml-1.5' : 'text-neutral-500 ml-1.5'} />
|
<Briefcase size={10} className={previewBrand ? 'text-violet-400 ml-1.5' : 'text-neutral-500 ml-1.5'} />
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface LivePreviewCanvasProps {
|
|||||||
onSceneChange?: (sceneId: string) => void;
|
onSceneChange?: (sceneId: string) => void;
|
||||||
/** External player ref */
|
/** External player ref */
|
||||||
playerRef?: React.RefObject<BradlyPlayerRef>;
|
playerRef?: React.RefObject<BradlyPlayerRef>;
|
||||||
|
/** Optional variation ID to apply */
|
||||||
|
variationId?: string;
|
||||||
/** Status label (e.g. "Listo" / "Faltan campos") */
|
/** Status label (e.g. "Listo" / "Faltan campos") */
|
||||||
statusLabel?: string;
|
statusLabel?: string;
|
||||||
/** Whether all required fields are complete */
|
/** Whether all required fields are complete */
|
||||||
@@ -69,6 +71,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
|
|||||||
statusLabel,
|
statusLabel,
|
||||||
isComplete = false,
|
isComplete = false,
|
||||||
videoDurations,
|
videoDurations,
|
||||||
|
variationId,
|
||||||
}) => {
|
}) => {
|
||||||
const internalRef = useRef<BradlyPlayerRef>(null);
|
const internalRef = useRef<BradlyPlayerRef>(null);
|
||||||
const playerRef = externalRef || internalRef;
|
const playerRef = externalRef || internalRef;
|
||||||
@@ -85,7 +88,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
|
|||||||
|
|
||||||
// Compile template to timeline (reactive to fieldData + mediaFits)
|
// Compile template to timeline (reactive to fieldData + mediaFits)
|
||||||
const compiled = useMemo(() => {
|
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
|
// Strip transitions and apply mediaFit overrides
|
||||||
result.elements = result.elements.map(el => {
|
result.elements = result.elements.map(el => {
|
||||||
const fieldId = el.sourceFieldId;
|
const fieldId = el.sourceFieldId;
|
||||||
@@ -100,7 +103,7 @@ export const LivePreviewCanvas: React.FC<LivePreviewCanvasProps> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}, [template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations]);
|
}, [template, fieldData, designMD, brand, mediaFits, containBgColors, videoDurations, variationId]);
|
||||||
|
|
||||||
const playerInputProps = useMemo(() => ({
|
const playerInputProps = useMemo(() => ({
|
||||||
designMD,
|
designMD,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 {
|
interface RenderJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,6 +9,7 @@ interface RenderJob {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
targetPath?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
@@ -18,13 +19,14 @@ interface RenderJob {
|
|||||||
interface RenderHistoryPanelProps {
|
interface RenderHistoryPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onDownload?: (job: RenderJob) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RenderHistoryPanel — Shows past and active render jobs with progress,
|
* RenderHistoryPanel — Shows past and active render jobs with progress,
|
||||||
* download links, and job status information.
|
* download links, and job status information.
|
||||||
*/
|
*/
|
||||||
export const RenderHistoryPanel: React.FC<RenderHistoryPanelProps> = ({ isOpen, onClose }) => {
|
export const RenderHistoryPanel: React.FC<RenderHistoryPanelProps> = ({ isOpen, onClose, onDownload = () => {} }) => {
|
||||||
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
const [jobs, setJobs] = useState<RenderJob[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -160,15 +162,23 @@ export const RenderHistoryPanel: React.FC<RenderHistoryPanelProps> = ({ isOpen,
|
|||||||
|
|
||||||
{/* Download button */}
|
{/* Download button */}
|
||||||
{job.status === 'done' && job.downloadUrl && (
|
{job.status === 'done' && job.downloadUrl && (
|
||||||
<a
|
<button
|
||||||
href={job.downloadUrl}
|
onClick={() => {
|
||||||
download
|
if ((window as any).electronAPI && job.targetPath) {
|
||||||
title="Descargar"
|
(window as any).electronAPI.fs.showItemInFolder(job.targetPath);
|
||||||
className="flex items-center gap-1 px-2 py-1 bg-emerald-600/20 text-emerald-300 rounded hover:bg-emerald-600/30 transition-colors"
|
} 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"
|
||||||
>
|
>
|
||||||
<Download size={10} />
|
<FolderOpen size={12} />
|
||||||
<span>Descargar</span>
|
<span>Abrir en carpeta</span>
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface RenderJobClient {
|
|||||||
fps: number;
|
fps: number;
|
||||||
durationInFrames: number;
|
durationInFrames: number;
|
||||||
compositionId: string;
|
compositionId: string;
|
||||||
|
brandId?: string;
|
||||||
|
targetPath?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -48,6 +50,7 @@ export interface ExportConfig {
|
|||||||
layers: TimelineLayer[];
|
layers: TimelineLayer[];
|
||||||
brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
|
brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
|
||||||
outputFormat?: 'video' | 'image';
|
outputFormat?: 'video' | 'image';
|
||||||
|
brandId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportCallbacks {
|
interface ExportCallbacks {
|
||||||
@@ -101,16 +104,18 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
const oldJob = idx >= 0 ? prev[idx] : null;
|
const oldJob = idx >= 0 ? prev[idx] : null;
|
||||||
|
|
||||||
// Check if it just finished
|
// 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];
|
const cbs = callbacksRef.current[updatedJob.id];
|
||||||
if (cbs?.onSuccess) {
|
if (cbs?.onSuccess) {
|
||||||
cbs.onSuccess(updatedJob.downloadUrl);
|
cbs.onSuccess(updatedJob.downloadUrl || updatedJob.targetPath || '');
|
||||||
}
|
}
|
||||||
delete callbacksRef.current[updatedJob.id];
|
delete callbacksRef.current[updatedJob.id];
|
||||||
|
showToast('Renderización completada con éxito', 'success');
|
||||||
if (!cbs?.onSuccess) {
|
|
||||||
// If there's no custom callback, show a generic toast
|
if (window.electronAPI && updatedJob.brandId && updatedJob.targetPath) {
|
||||||
showToast('Renderización completada', 'success');
|
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') {
|
} else if (updatedJob.status === 'error' && oldJob?.status !== 'error') {
|
||||||
const cbs = callbacksRef.current[updatedJob.id];
|
const cbs = callbacksRef.current[updatedJob.id];
|
||||||
@@ -157,8 +162,13 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
eventSourceRef.current?.close();
|
if (eventSourceRef.current) {
|
||||||
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
@@ -213,6 +223,13 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
outputFormat: config.outputFormat,
|
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 = {
|
const body = {
|
||||||
format: config.format,
|
format: config.format,
|
||||||
width: config.width,
|
width: config.width,
|
||||||
@@ -220,6 +237,8 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
fps: config.fps,
|
fps: config.fps,
|
||||||
durationInFrames: isStill ? 1 : config.durationInFrames,
|
durationInFrames: isStill ? 1 : config.durationInFrames,
|
||||||
compositionId: isStill ? 'BrandStill' : 'BrandVideo',
|
compositionId: isStill ? 'BrandStill' : 'BrandVideo',
|
||||||
|
brandId: config.brandId,
|
||||||
|
targetPath,
|
||||||
inputProps,
|
inputProps,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,22 +284,16 @@ export const ExportQueueProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
|||||||
const downloadJob = useCallback(async (job: RenderJobClient) => {
|
const downloadJob = useCallback(async (job: RenderJobClient) => {
|
||||||
if (!job.downloadUrl) return;
|
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;
|
const electronAPI = (window as any).electronAPI;
|
||||||
if (electronAPI?.saveRenderedFile) {
|
|
||||||
try {
|
// In Electron, if it has a targetPath, just open the folder
|
||||||
const savedPath = await electronAPI.saveRenderedFile(job.downloadUrl, defaultName);
|
if (electronAPI?.fs?.showItemInFolder && job.targetPath) {
|
||||||
if (savedPath) {
|
await electronAPI.fs.showItemInFolder(job.targetPath);
|
||||||
console.log('✅ Saved to:', savedPath);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultName = `export-${job.id.slice(0, 8)}.${job.format}`;
|
||||||
|
|
||||||
// Web fallback: <a> tag download
|
// Web fallback: <a> tag download
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = job.downloadUrl;
|
a.href = job.downloadUrl;
|
||||||
|
|||||||
@@ -128,6 +128,15 @@ export interface TemplateBuilderState {
|
|||||||
introScene: ExpressScene | null;
|
introScene: ExpressScene | null;
|
||||||
/** The outro scene (first scene with type 'outro'), or null */
|
/** The outro scene (first scene with type 'outro'), or null */
|
||||||
outroScene: ExpressScene | 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<TemplateBuilderState | null>(null);
|
const TemplateBuilderContext = createContext<TemplateBuilderState | null>(null);
|
||||||
@@ -234,6 +243,14 @@ export const TemplateBuilderProvider: React.FC<TemplateBuilderProviderProps> = (
|
|||||||
const [activeSceneId, setActiveSceneId] = useState<string | null>(initialScenes[0]?.id || null);
|
const [activeSceneId, setActiveSceneId] = useState<string | null>(initialScenes[0]?.id || null);
|
||||||
const activeScene = scenes.find(s => s.id === activeSceneId) || null;
|
const activeScene = scenes.find(s => s.id === activeSceneId) || null;
|
||||||
|
|
||||||
|
// ── Variations ──
|
||||||
|
const [activeVariationId, setActiveVariationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const updateActiveScene = useCallback((updates: Partial<ExpressScene>) => {
|
||||||
|
if (!activeSceneId) return;
|
||||||
|
setScenes(prev => prev.map(s => s.id === activeSceneId ? { ...s, ...updates } : s));
|
||||||
|
}, [activeSceneId]);
|
||||||
|
|
||||||
// ── Per-scene fields map ──
|
// ── Per-scene fields map ──
|
||||||
const [sceneFieldsMap, setSceneFieldsMap] = useState<Record<string, TemplateField[]>>(() => {
|
const [sceneFieldsMap, setSceneFieldsMap] = useState<Record<string, TemplateField[]>>(() => {
|
||||||
const map: Record<string, TemplateField[]> = {};
|
const map: Record<string, TemplateField[]> = {};
|
||||||
@@ -299,10 +316,41 @@ export const TemplateBuilderProvider: React.FC<TemplateBuilderProviderProps> = (
|
|||||||
|
|
||||||
const updateFieldCb = useCallback((id: string, updates: Partial<TemplateField>) => {
|
const updateFieldCb = useCallback((id: string, updates: Partial<TemplateField>) => {
|
||||||
if (!activeSceneId) return;
|
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 =>
|
updateSceneFields(activeSceneId, prev =>
|
||||||
prev.map(f => f.id === id ? { ...f, ...updates } : f)
|
prev.map(f => f.id === id ? { ...f, ...updates } : f)
|
||||||
);
|
);
|
||||||
}, [activeSceneId, updateSceneFields]);
|
}, [activeSceneId, activeVariationId, updateSceneFields]);
|
||||||
|
|
||||||
const removeField = useCallback((id: string) => {
|
const removeField = useCallback((id: string) => {
|
||||||
if (!activeSceneId) return;
|
if (!activeSceneId) return;
|
||||||
@@ -440,6 +488,35 @@ export const TemplateBuilderProvider: React.FC<TemplateBuilderProviderProps> = (
|
|||||||
setScenes(prev => prev.map(s => s.id === sceneId ? { ...s, ...updates } : s));
|
setScenes(prev => prev.map(s => s.id === sceneId ? { ...s, ...updates } : s));
|
||||||
}, [setScenes]);
|
}, [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 ──
|
// ── Expose getSceneFieldsMap for save ──
|
||||||
// We attach it to the context so TemplateBuilder can access all scene fields at save time
|
// We attach it to the context so TemplateBuilder can access all scene fields at save time
|
||||||
const value: TemplateBuilderState & { _sceneFieldsMap: Record<string, TemplateField[]> } = {
|
const value: TemplateBuilderState & { _sceneFieldsMap: Record<string, TemplateField[]> } = {
|
||||||
@@ -496,6 +573,14 @@ export const TemplateBuilderProvider: React.FC<TemplateBuilderProviderProps> = (
|
|||||||
introScene,
|
introScene,
|
||||||
outroScene,
|
outroScene,
|
||||||
|
|
||||||
|
// Variation management
|
||||||
|
activeVariationId,
|
||||||
|
setActiveVariationId,
|
||||||
|
addVariation,
|
||||||
|
deleteVariation,
|
||||||
|
updateVariationName,
|
||||||
|
resolveFieldPosition,
|
||||||
|
|
||||||
// Internal: for save access
|
// Internal: for save access
|
||||||
_sceneFieldsMap: sceneFieldsMap,
|
_sceneFieldsMap: sceneFieldsMap,
|
||||||
};
|
};
|
||||||
|
|||||||
+292
-2
@@ -6,7 +6,7 @@
|
|||||||
* - Starts the embedded Express server
|
* - Starts the embedded Express server
|
||||||
* - Manages IPC handlers for native features
|
* - 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 path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
@@ -123,7 +123,7 @@ function createWindow() {
|
|||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#0a0a0a',
|
||||||
show: false, // Show when ready to prevent visual flash
|
show: false, // Show when ready to prevent visual flash
|
||||||
webPreferences: {
|
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,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: false, // Required for preload to access Node APIs
|
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 ═══
|
// ═══ IPC Handlers ═══
|
||||||
|
|
||||||
function setupIPC() {
|
function setupIPC() {
|
||||||
@@ -248,6 +285,259 @@ function setupIPC() {
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,26 @@ const electronAPI = {
|
|||||||
userData: string;
|
userData: string;
|
||||||
isPackaged: boolean;
|
isPackaged: boolean;
|
||||||
}>,
|
}>,
|
||||||
|
|
||||||
|
// ─── File System (fs) ───
|
||||||
|
fs: {
|
||||||
|
getWorkspacePath: () => ipcRenderer.invoke('fs:getWorkspacePath') as Promise<string>,
|
||||||
|
setWorkspacePath: () => ipcRenderer.invoke('fs:setWorkspacePath') as Promise<string | null>,
|
||||||
|
getBrands: () => ipcRenderer.invoke('fs:getBrands') as Promise<any[]>,
|
||||||
|
saveBrand: (brand: any) => ipcRenderer.invoke('fs:saveBrand', brand) as Promise<boolean>,
|
||||||
|
deleteBrand: (brandId: string) => ipcRenderer.invoke('fs:deleteBrand', brandId) as Promise<boolean>,
|
||||||
|
getTemplates: () => ipcRenderer.invoke('fs:getTemplates') as Promise<any[]>,
|
||||||
|
saveTemplate: (template: any) => ipcRenderer.invoke('fs:saveTemplate', template) as Promise<boolean>,
|
||||||
|
deleteTemplate: (templateId: string) => ipcRenderer.invoke('fs:deleteTemplate', templateId) as Promise<boolean>,
|
||||||
|
openFolder: (path: string) => ipcRenderer.invoke('fs:openFolder', path) as Promise<boolean>,
|
||||||
|
showItemInFolder: (path: string) => ipcRenderer.invoke('fs:showItemInFolder', path) as Promise<boolean>,
|
||||||
|
getNextFilename: (brandId: string, type: 'video' | 'image', ext: string) => ipcRenderer.invoke('fs:getNextFilename', brandId, type, ext) as Promise<string>,
|
||||||
|
registerGeneratedMedia: (brandId: string, type: 'video' | 'image', filePath: string) => ipcRenderer.invoke('fs:registerGeneratedMedia', brandId, type, filePath) as Promise<boolean>,
|
||||||
|
getGeneratedMedia: (brandId: string, type: 'video' | 'image') => ipcRenderer.invoke('fs:getGeneratedMedia', brandId, type) as Promise<any[]>,
|
||||||
|
renameGeneratedMedia: (brandId: string, type: 'video' | 'image', oldPath: string, newName: string) => ipcRenderer.invoke('fs:renameGeneratedMedia', brandId, type, oldPath, newName) as Promise<string | false>,
|
||||||
|
getContentMesh: () => ipcRenderer.invoke('fs:getContentMesh') as Promise<any>,
|
||||||
|
saveContentMesh: (data: any) => ipcRenderer.invoke('fs:saveContentMesh', data) as Promise<boolean>,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expose to the renderer process
|
// Expose to the renderer process
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface UseBatchProductionResult {
|
|||||||
invalidCount: number;
|
invalidCount: number;
|
||||||
setBackgroundFiles: (files: File[]) => void;
|
setBackgroundFiles: (files: File[]) => void;
|
||||||
updatePieceField: (index: number, fieldId: string, value: string) => void;
|
updatePieceField: (index: number, fieldId: string, value: string) => void;
|
||||||
|
updatePieceVariation: (index: number, variationId: string | null) => void;
|
||||||
importCSV: (file: File) => Promise<{ matched: number; unmatched: number }>;
|
importCSV: (file: File) => Promise<{ matched: number; unmatched: number }>;
|
||||||
removePiece: (index: number) => void;
|
removePiece: (index: number) => void;
|
||||||
clearBatch: () => 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 ───
|
// ─── Validate all pieces ───
|
||||||
const validateAll = useCallback((): boolean => {
|
const validateAll = useCallback((): boolean => {
|
||||||
let allOk = true;
|
let allOk = true;
|
||||||
@@ -243,6 +252,7 @@ export function useBatchProduction(
|
|||||||
invalidCount,
|
invalidCount,
|
||||||
setBackgroundFiles,
|
setBackgroundFiles,
|
||||||
updatePieceField,
|
updatePieceField,
|
||||||
|
updatePieceVariation,
|
||||||
importCSV,
|
importCSV,
|
||||||
removePiece,
|
removePiece,
|
||||||
clearBatch,
|
clearBatch,
|
||||||
|
|||||||
@@ -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<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | 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);
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,9 @@ export interface RenderJob {
|
|||||||
fps: number;
|
fps: number;
|
||||||
durationInFrames: number;
|
durationInFrames: number;
|
||||||
compositionId: string;
|
compositionId: string;
|
||||||
|
brandId?: string;
|
||||||
inputProps: Record<string, any>;
|
inputProps: Record<string, any>;
|
||||||
|
targetPath?: string;
|
||||||
outputPath?: string;
|
outputPath?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -51,7 +53,9 @@ export interface RenderJobCreateParams {
|
|||||||
fps: number;
|
fps: number;
|
||||||
durationInFrames: number;
|
durationInFrames: number;
|
||||||
compositionId: string;
|
compositionId: string;
|
||||||
|
brandId?: string;
|
||||||
inputProps: Record<string, any>;
|
inputProps: Record<string, any>;
|
||||||
|
targetPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Constants ═══
|
// ═══ Constants ═══
|
||||||
@@ -190,7 +194,13 @@ async function renderJob(job: RenderJob): Promise<void> {
|
|||||||
const serveUrl = process.env.BRADLY_SERVE_URL || DEFAULT_SERVE_URL;
|
const serveUrl = process.env.BRADLY_SERVE_URL || DEFAULT_SERVE_URL;
|
||||||
const isStill = job.format === 'png' || job.format === 'jpeg';
|
const isStill = job.format === 'png' || job.format === 'jpeg';
|
||||||
const ext = job.format;
|
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})`);
|
console.log(`🎬 Rendering [${job.id}] → ${job.format} (${job.width}×${job.height})`);
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ export interface BatchPieceData {
|
|||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
/** Validation errors per field */
|
/** Validation errors per field */
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
|
/** Optional variation ID */
|
||||||
|
variationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Express Editor Types ═══
|
// ═══ 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<string, { x: number; y: number; w: number; h: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single scene block in the storyboard */
|
/** A single scene block in the storyboard */
|
||||||
export interface ExpressScene {
|
export interface ExpressScene {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -532,6 +542,8 @@ export interface ExpressScene {
|
|||||||
editableFields: ExpressField[];
|
editableFields: ExpressField[];
|
||||||
/** New schema-based fields (used by redesigned builder) */
|
/** New schema-based fields (used by redesigned builder) */
|
||||||
fields?: TemplateField[];
|
fields?: TemplateField[];
|
||||||
|
/** Template design variations (allow repositioning fields) */
|
||||||
|
variations?: TemplateVariation[];
|
||||||
/** Scene-level transition animation */
|
/** Scene-level transition animation */
|
||||||
transition?: { type: string; duration: number };
|
transition?: { type: string; duration: number };
|
||||||
/** Background: solid color, gradient, or media (user-replaceable) */
|
/** Background: solid color, gradient, or media (user-replaceable) */
|
||||||
|
|||||||
+23
-47
@@ -58,7 +58,8 @@ async function renderPieceToImage(
|
|||||||
backgroundFieldId: string | null,
|
backgroundFieldId: string | null,
|
||||||
dimensions: { w: number; h: number },
|
dimensions: { w: number; h: number },
|
||||||
options: BatchExportOptions,
|
options: BatchExportOptions,
|
||||||
): Promise<Blob> {
|
targetPath?: string,
|
||||||
|
): Promise<void> {
|
||||||
// Build fieldData with background injected
|
// Build fieldData with background injected
|
||||||
const rawFieldData: Record<string, string> = { ...piece.fieldData };
|
const rawFieldData: Record<string, string> = { ...piece.fieldData };
|
||||||
if (backgroundFieldId && piece.backgroundUrl) {
|
if (backgroundFieldId && piece.backgroundUrl) {
|
||||||
@@ -68,7 +69,7 @@ async function renderPieceToImage(
|
|||||||
// Resolve blob: URLs to persistent server URLs
|
// Resolve blob: URLs to persistent server URLs
|
||||||
const fieldData = await resolveBlobFieldData(rawFieldData);
|
const fieldData = await resolveBlobFieldData(rawFieldData);
|
||||||
|
|
||||||
const compiled = compileExpressToTimeline(template, fieldData, designMD, brand);
|
const compiled = compileExpressToTimeline(template, fieldData, designMD, brand, undefined, piece.variationId);
|
||||||
// Strip transitions
|
// Strip transitions
|
||||||
compiled.elements = compiled.elements.map(el => ({
|
compiled.elements = compiled.elements.map(el => ({
|
||||||
...el,
|
...el,
|
||||||
@@ -96,6 +97,7 @@ async function renderPieceToImage(
|
|||||||
durationInFrames: 1,
|
durationInFrames: 1,
|
||||||
compositionId: 'BrandStill',
|
compositionId: 'BrandStill',
|
||||||
inputProps,
|
inputProps,
|
||||||
|
targetPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch('/api/render/start', {
|
const res = await fetch('/api/render/start', {
|
||||||
@@ -122,10 +124,8 @@ async function renderPieceToImage(
|
|||||||
if (!statusRes.ok) continue;
|
if (!statusRes.ok) continue;
|
||||||
const statusData = await statusRes.json();
|
const statusData = await statusRes.json();
|
||||||
|
|
||||||
if (statusData.status === 'done' && statusData.downloadUrl) {
|
if (statusData.status === 'done') {
|
||||||
const fileRes = await fetch(statusData.downloadUrl);
|
return; // Finished rendering to targetPath!
|
||||||
if (!fileRes.ok) throw new Error(`Download failed for piece ${piece.index + 1}`);
|
|
||||||
return await fileRes.blob();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusData.status === 'error') {
|
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 pieces - Array of batch pieces to render
|
||||||
* @param template - The Express template
|
* @param template - The Express template
|
||||||
* @param brand - Brand profile (for DesignMD + brand variables)
|
* @param brand - Brand profile
|
||||||
* @param options - Export format options
|
* @param options - Export format options
|
||||||
* @param onProgress - Progress callback
|
* @param onProgress - Progress callback
|
||||||
* @returns Promise that resolves when download starts
|
|
||||||
*/
|
*/
|
||||||
export async function exportBatchAsZip(
|
export async function exportBatchToDisk(
|
||||||
pieces: BatchPieceData[],
|
pieces: BatchPieceData[],
|
||||||
template: ExpressTemplate,
|
template: ExpressTemplate,
|
||||||
brand: CompanyProfile,
|
brand: CompanyProfile,
|
||||||
@@ -156,10 +155,10 @@ export async function exportBatchAsZip(
|
|||||||
const designMD = brand.design;
|
const designMD = brand.design;
|
||||||
const dimensions = getAspectDimensions(template.aspectRatio);
|
const dimensions = getAspectDimensions(template.aspectRatio);
|
||||||
const backgroundFieldId = findBackgroundFieldId(template);
|
const backgroundFieldId = findBackgroundFieldId(template);
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
const validPieces = pieces.filter(p => p.isValid);
|
const validPieces = pieces.filter(p => p.isValid);
|
||||||
const total = validPieces.length;
|
const total = validPieces.length;
|
||||||
|
const ext = options.format === 'jpeg' ? 'jpg' : 'png';
|
||||||
|
|
||||||
onProgress?.({ current: 0, total, status: 'rendering' });
|
onProgress?.({ current: 0, total, status: 'rendering' });
|
||||||
|
|
||||||
@@ -167,50 +166,27 @@ export async function exportBatchAsZip(
|
|||||||
const piece = validPieces[i];
|
const piece = validPieces[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await renderPieceToImage(
|
let targetPath: string | undefined;
|
||||||
piece, template, designMD, brand, backgroundFieldId, dimensions, options,
|
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
|
// Register the file in the brand's JSON
|
||||||
const ext = options.format === 'jpeg' ? 'jpg' : 'png';
|
if (electronAPI?.fs && targetPath) {
|
||||||
const baseName = piece.backgroundFilename
|
await electronAPI.fs.registerGeneratedMedia(brand.id, 'image', targetPath);
|
||||||
? piece.backgroundFilename.replace(/\.[^.]+$/, '')
|
}
|
||||||
: `pieza-${piece.index + 1}`;
|
|
||||||
const fileName = `${baseName}.${ext}`;
|
|
||||||
|
|
||||||
zip.file(fileName, blob);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to render piece ${piece.index + 1}:`, 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: 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' });
|
onProgress?.({ current: total, total, status: 'done' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export function compileExpressToTimeline(
|
|||||||
designMD: DesignMD,
|
designMD: DesignMD,
|
||||||
company?: CompanyProfile,
|
company?: CompanyProfile,
|
||||||
videoDurations?: Record<string, number>,
|
videoDurations?: Record<string, number>,
|
||||||
|
variationId?: string,
|
||||||
): { elements: TimelineElement[]; layers: TimelineLayer[] } {
|
): { elements: TimelineElement[]; layers: TimelineLayer[] } {
|
||||||
const fps = 30;
|
const fps = 30;
|
||||||
const elements: TimelineElement[] = [];
|
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)
|
const fieldsToProcess = (scene.fields && scene.fields.length > 0)
|
||||||
? scene.fields
|
? scene.fields
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const activeVariation = variationId && scene.variations ? scene.variations.find(v => v.id === variationId) : null;
|
||||||
|
|
||||||
if (fieldsToProcess) {
|
if (fieldsToProcess) {
|
||||||
// New TemplateField[] format: process ALL natures
|
// New TemplateField[] format: process ALL natures
|
||||||
for (const field of fieldsToProcess) {
|
for (const field of fieldsToProcess) {
|
||||||
|
const position = activeVariation && activeVariation.positions[field.id]
|
||||||
|
? { ...field.position, ...activeVariation.positions[field.id] }
|
||||||
|
: field.position;
|
||||||
|
|
||||||
let value: string;
|
let value: string;
|
||||||
|
|
||||||
if (field.nature === 'static') {
|
if (field.nature === 'static') {
|
||||||
@@ -346,12 +352,12 @@ export function compileExpressToTimeline(
|
|||||||
sourceFieldId: field.id,
|
sourceFieldId: field.id,
|
||||||
type: elType,
|
type: elType,
|
||||||
content: field.type === 'sticker' ? compiledContent : (value || ''),
|
content: field.type === 'sticker' ? compiledContent : (value || ''),
|
||||||
x: field.position.x,
|
x: position.x,
|
||||||
y: field.position.y,
|
y: position.y,
|
||||||
startFrame: sceneStart,
|
startFrame: sceneStart,
|
||||||
endFrame: sceneEnd,
|
endFrame: sceneEnd,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
rotation: field.position.rotation || 0,
|
rotation: position.rotation || 0,
|
||||||
opacity: field.style.opacity ?? 100,
|
opacity: field.style.opacity ?? 100,
|
||||||
blendMode: field.style.blendMode,
|
blendMode: field.style.blendMode,
|
||||||
layerId,
|
layerId,
|
||||||
@@ -372,8 +378,8 @@ export function compileExpressToTimeline(
|
|||||||
textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || (field.type === 'sticker' ? 'left' : 'center'),
|
textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || (field.type === 'sticker' ? 'left' : 'center'),
|
||||||
} : {}),
|
} : {}),
|
||||||
...(field.type === 'image' || field.type === 'video' ? {
|
...(field.type === 'image' || field.type === 'video' ? {
|
||||||
width: field.position.w,
|
width: position.w,
|
||||||
height: field.position.h,
|
height: position.h,
|
||||||
objectFit: ((field.nature === 'brand-variable' && field.brandSource === 'intro-video')
|
objectFit: ((field.nature === 'brand-variable' && field.brandSource === 'intro-video')
|
||||||
? (designMD.introVideoFit || field.style.mediaFit || 'cover')
|
? (designMD.introVideoFit || field.style.mediaFit || 'cover')
|
||||||
: (field.nature === 'brand-variable' && field.brandSource === 'outro-video')
|
: (field.nature === 'brand-variable' && field.brandSource === 'outro-video')
|
||||||
@@ -386,8 +392,8 @@ export function compileExpressToTimeline(
|
|||||||
: undefined,
|
: undefined,
|
||||||
} : {}),
|
} : {}),
|
||||||
...(field.type === 'shape' ? {
|
...(field.type === 'shape' ? {
|
||||||
width: field.position.w,
|
width: position.w,
|
||||||
height: field.position.h,
|
height: position.h,
|
||||||
shapeType: field.style.shapeType || 'rectangle',
|
shapeType: field.style.shapeType || 'rectangle',
|
||||||
color: field.style.shapeFill || designMD.primaryColor,
|
color: field.style.shapeFill || designMD.primaryColor,
|
||||||
} : {}),
|
} : {}),
|
||||||
@@ -397,6 +403,10 @@ export function compileExpressToTimeline(
|
|||||||
} else {
|
} else {
|
||||||
// Legacy ExpressField[] format
|
// Legacy ExpressField[] format
|
||||||
for (const field of scene.editableFields) {
|
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);
|
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
|
// For media fields, placeholder text is not a valid URL — clear it to avoid crashing Remotion
|
||||||
const isLegacyMedia = field.type === 'media' || field.type === 'logo';
|
const isLegacyMedia = field.type === 'media' || field.type === 'logo';
|
||||||
@@ -421,8 +431,8 @@ export function compileExpressToTimeline(
|
|||||||
sourceFieldId: field.id,
|
sourceFieldId: field.id,
|
||||||
type: elType,
|
type: elType,
|
||||||
content: value || '',
|
content: value || '',
|
||||||
x: field.position.x,
|
x: position.x,
|
||||||
y: field.position.y,
|
y: position.y,
|
||||||
startFrame: sceneStart,
|
startFrame: sceneStart,
|
||||||
endFrame: sceneEnd,
|
endFrame: sceneEnd,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -444,11 +454,11 @@ export function compileExpressToTimeline(
|
|||||||
fontFamily: resolveFont(field, designMD),
|
fontFamily: resolveFont(field, designMD),
|
||||||
color: resolveColor(field, designMD),
|
color: resolveColor(field, designMD),
|
||||||
textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || 'center',
|
textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || 'center',
|
||||||
width: field.position.w,
|
width: position.w,
|
||||||
} : {}),
|
} : {}),
|
||||||
...(field.type === 'media' || field.type === 'logo' ? {
|
...(field.type === 'media' || field.type === 'logo' ? {
|
||||||
width: field.position.w,
|
width: position.w,
|
||||||
height: field.position.h,
|
height: position.h,
|
||||||
objectFit: 'cover' as const,
|
objectFit: 'cover' as const,
|
||||||
} : {}),
|
} : {}),
|
||||||
transitionIn: scene.transition ? { type: scene.transition.type as TransitionType, duration: scene.transition.duration } : undefined,
|
transitionIn: scene.transition ? { type: scene.transition.type as TransitionType, duration: scene.transition.duration } : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user