From 5b895d830bc0af1a86fb41dfee4bd388cf284134 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Wed, 7 Oct 2026 12:14:14 +0200 Subject: [PATCH] Add creative image upload to the new pipeline piece form. Agency can pick cover (and carousel slides) when creating a piece instead of only after the card exists. Co-authored-by: Cursor --- src/components/CreativePipelineBoard.tsx | 287 +++++++++++++++++++---- 1 file changed, 235 insertions(+), 52 deletions(-) diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index febcf78..66cd938 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -153,11 +153,63 @@ export default function CreativePipelineBoard() { const [newDate, setNewDate] = useState(''); const [newTime, setNewTime] = useState('10:00'); const [syncCalendar, setSyncCalendar] = useState(true); + const [coverFile, setCoverFile] = useState(null); + const [coverPreview, setCoverPreview] = useState(null); + const [slideFiles, setSlideFiles] = useState([]); + const [slidePreviews, setSlidePreviews] = useState([]); + const [creating, setCreating] = useState(false); const [selectedMonth, setSelectedMonth] = useState('all'); const [selectedFormat, setSelectedFormat] = useState('all'); const [previewItem, setPreviewItem] = useState(null); const [viewMode, setViewMode] = useState<'produccion' | 'tablon'>('produccion'); + function clearMediaSelection() { + if (coverPreview) URL.revokeObjectURL(coverPreview); + for (const u of slidePreviews) URL.revokeObjectURL(u); + setCoverFile(null); + setCoverPreview(null); + setSlideFiles([]); + setSlidePreviews([]); + } + + function pickCover(file: File | null) { + if (coverPreview) URL.revokeObjectURL(coverPreview); + if (!file) { + setCoverFile(null); + setCoverPreview(null); + return; + } + setCoverFile(file); + setCoverPreview(URL.createObjectURL(file)); + } + + function pickSlides(files: FileList | null) { + for (const u of slidePreviews) URL.revokeObjectURL(u); + const list = files ? Array.from(files).slice(0, 9) : []; + setSlideFiles(list); + setSlidePreviews(list.map((f) => URL.createObjectURL(f))); + } + + async function uploadFileToItem( + itemId: string, + file: File, + slot: 'cover' | 'slide', + index = 0 + ): Promise { + const fd = new FormData(); + fd.append('file', file); + fd.append('itemId', itemId); + fd.append('slot', slot); + fd.append('index', String(index)); + const res = await fetch('/api/creative-pipeline/upload', { + method: 'POST', + body: fd, + }); + const data = await res.json(); + if (!res.ok) return data.error || 'Error al subir imagen'; + return null; + } + async function load() { setLoading(true); try { @@ -200,6 +252,7 @@ export default function CreativePipelineBoard() { setNewMonth(''); setNewDate(''); setNewTime('10:00'); + clearMediaSelection(); return; } const p = calPieces.find((x) => x.id === id); @@ -227,6 +280,7 @@ export default function CreativePipelineBoard() { setNewDate(''); setNewTime('10:00'); setSyncCalendar(true); + clearMediaSelection(); setShowForm(false); } @@ -283,65 +337,102 @@ export default function CreativePipelineBoard() { e.preventDefault(); if (!newTitle.trim()) return; setErr(null); + setCreating(true); - let calendarPieceId: string | null = - calPick !== '__new__' ? calPick : null; + try { + let calendarPieceId: string | null = + calPick !== '__new__' ? calPick : null; - if (syncCalendar) { - const date = - newDate || - (newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10)); - const calFmt = mapPipelineFormatToCalendar(newFormat); - const calRes = await fetch('/api/calendar/pieces', { - method: 'PATCH', + if (syncCalendar) { + const date = + newDate || + (newMonth ? `${newMonth}-01` : new Date().toISOString().slice(0, 10)); + const calFmt = mapPipelineFormatToCalendar(newFormat); + const calRes = await fetch('/api/calendar/pieces', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pieceId: calendarPieceId, + title: newTitle.trim(), + date, + time: newTime || '10:00', + format: calFmt.format, + kind: calFmt.kind, + hook: newHook || undefined, + body: newCaption || undefined, + pillar: newPillar || undefined, + platform: 'instagram', + }), + }); + const calData = await calRes.json(); + if (!calRes.ok) { + setErr(calData.error || 'No se pudo guardar en el calendario RRSS'); + return; + } + calendarPieceId = calData.piece?.id || calendarPieceId; + void loadCalendarPieces(); + } + + const sched = newDate + ? scheduledAtFromPiece({ date: newDate, time: newTime }) + : null; + + const res = await fetch('/api/creative-pipeline', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - pieceId: calendarPieceId, title: newTitle.trim(), - date, - time: newTime || '10:00', - format: calFmt.format, - kind: calFmt.kind, - hook: newHook || undefined, - body: newCaption || undefined, - pillar: newPillar || undefined, - platform: 'instagram', + format: newFormat, + month: newMonth || (newDate ? newDate.slice(0, 7) : null), + caption: newCaption || null, + notes: newPillar ? `Pilar: ${newPillar}` : null, + calendar_piece_id: calendarPieceId, + scheduled_at: sched, + channels: ['instagram', 'facebook'], }), }); - const calData = await calRes.json(); - if (!calRes.ok) { - setErr(calData.error || 'No se pudo guardar en el calendario RRSS'); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'Error al crear'); return; } - calendarPieceId = calData.piece?.id || calendarPieceId; - void loadCalendarPieces(); - } - const sched = newDate - ? scheduledAtFromPiece({ date: newDate, time: newTime }) - : null; + const newId = data.item?.id as string | undefined; + if (newId && coverFile) { + const upErr = await uploadFileToItem(newId, coverFile, 'cover', 0); + if (upErr) { + setErr( + `Pieza creada, pero falló la portada: ${upErr}. Puedes subirla desde la tarjeta.` + ); + resetForm(); + await load(); + return; + } + } + if (newId && newFormat === 'carrusel' && slideFiles.length) { + for (let i = 0; i < slideFiles.length; i++) { + const upErr = await uploadFileToItem( + newId, + slideFiles[i], + 'slide', + i + ); + if (upErr) { + setErr( + `Pieza creada; error en slide ${i + 1}: ${upErr}. Completa el resto en la tarjeta.` + ); + resetForm(); + await load(); + return; + } + } + } - const res = await fetch('/api/creative-pipeline', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: newTitle.trim(), - format: newFormat, - month: newMonth || (newDate ? newDate.slice(0, 7) : null), - caption: newCaption || null, - notes: newPillar ? `Pilar: ${newPillar}` : null, - calendar_piece_id: calendarPieceId, - scheduled_at: sched, - channels: ['instagram', 'facebook'], - }), - }); - const data = await res.json(); - if (!res.ok) { - setErr(data.error || 'Error al crear'); - return; + resetForm(); + await load(); + } finally { + setCreating(false); } - resetForm(); - await load(); } async function setStatus(id: string, status: string) { @@ -847,6 +938,92 @@ export default function CreativePipelineBoard() { placeholder="Texto completo del anuncio" /> + +
+ +

+ Sube la portada aquí (jpg/png/webp). En carrusel puedes añadir + slides extra. +

+
+ + {coverFile ? ( +
+

+ {coverFile.name} +

+

+ {(coverFile.size / 1024).toFixed(0)} KB +

+ +
+ ) : null} +
+ + {newFormat === 'carrusel' ? ( +
+ + pickSlides(e.target.files)} + /> + {slidePreviews.length > 0 ? ( +
+ {slidePreviews.map((src, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Slide +
+ ))} +
+ ) : null} +
+ ) : null} +