Spaces:
Sleeping
Sleeping
| import { useState, useEffect } from 'react' | |
| import { Link } from 'react-router-dom' | |
| import JSZip from 'jszip' | |
| import ImageLightbox from './ImageLightbox' | |
| import { buildCreativeAssetBaseName } from './naming' | |
| const API_BASE = '/api' | |
| const AUTH_TOKEN_KEY = 'amalfa_auth_token' | |
| const GALLERY_PAGE_SIZE = 48 | |
| /** Return the display URL for a gallery image or external image. */ | |
| function proxyImageUrl(url) { | |
| if (!url) return '' | |
| // Internal backend gallery image — served directly, no auth needed (UUID = capability URL) | |
| if (url.startsWith('/api/gallery/image/')) return url | |
| // External presigned URL — route through our proxy to avoid CORS | |
| return `${API_BASE}/proxy-image?url=${encodeURIComponent(url)}` | |
| } | |
| function authHeaders() { | |
| const token = typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null | |
| if (!token) return {} | |
| return { Authorization: `Bearer ${token}` } | |
| } | |
| export default function Gallery({ onLogout }) { | |
| const [creatives, setCreatives] = useState([]) | |
| const [total, setTotal] = useState(0) | |
| const [page, setPage] = useState(0) | |
| const [loading, setLoading] = useState(true) | |
| const [error, setError] = useState(null) | |
| const [selected, setSelected] = useState([]) | |
| const [downloading, setDownloading] = useState(false) | |
| const [deletingId, setDeletingId] = useState(null) | |
| const [lightboxImage, setLightboxImage] = useState(null) | |
| const [refresh, setRefresh] = useState(0) | |
| const [correctionCreative, setCorrectionCreative] = useState(null) | |
| const [canvaStatus, setCanvaStatus] = useState({ configured: false, connected: false }) | |
| const [exportingToCanvaId, setExportingToCanvaId] = useState(null) | |
| const totalPages = Math.max(1, Math.ceil(total / GALLERY_PAGE_SIZE)) | |
| useEffect(() => { | |
| fetch(`${API_BASE}/canva/status`, { headers: authHeaders() }) | |
| .then((r) => (r.status === 401 ? null : r.json())) | |
| .then((data) => data && setCanvaStatus({ configured: !!data.configured, connected: !!data.connected })) | |
| .catch(() => {}) | |
| }, [refresh]) | |
| // Refetch Canva status when returning from OAuth | |
| useEffect(() => { | |
| const params = new URLSearchParams(window.location.search) | |
| if (params.get('canva') === 'connected' || params.get('canva') === 'error') { | |
| params.delete('canva') | |
| const newSearch = params.toString() | |
| window.history.replaceState({}, '', window.location.pathname + (newSearch ? `?${newSearch}` : '')) | |
| fetch(`${API_BASE}/canva/status`, { headers: authHeaders() }) | |
| .then((r) => (r.ok ? r.json() : null)) | |
| .then((data) => data && setCanvaStatus({ configured: !!data.configured, connected: !!data.connected })) | |
| .catch(() => {}) | |
| } | |
| }, []) | |
| useEffect(() => { | |
| let cancelled = false | |
| setLoading(true) | |
| setError(null) | |
| const offset = page * GALLERY_PAGE_SIZE | |
| fetch(`${API_BASE}/gallery/creatives?limit=${GALLERY_PAGE_SIZE}&offset=${offset}`, { headers: authHeaders() }) | |
| .then((r) => { | |
| if (r.status === 401) { | |
| localStorage.removeItem(AUTH_TOKEN_KEY) | |
| onLogout?.() | |
| return null | |
| } | |
| if (!r.ok) throw new Error('Failed to load gallery') | |
| return r.json() | |
| }) | |
| .then((data) => { | |
| if (!cancelled && data) { | |
| const list = data.creatives || [] | |
| const totalCount = data.total ?? 0 | |
| setCreatives(list) | |
| setTotal(totalCount) | |
| if (list.length === 0 && totalCount > 0 && page > 0) setPage(0) | |
| } | |
| }) | |
| .catch((e) => { if (!cancelled) setError(e.message) }) | |
| .finally(() => { if (!cancelled) setLoading(false) }) | |
| return () => { cancelled = true } | |
| }, [onLogout, page, refresh]) | |
| function toggleSelect(id) { | |
| setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) | |
| } | |
| async function handleDelete(id) { | |
| setDeletingId(id) | |
| try { | |
| const res = await fetch(`${API_BASE}/gallery/creatives/${id}`, { | |
| method: 'DELETE', | |
| headers: authHeaders(), | |
| }) | |
| if (res.status === 401) { | |
| localStorage.removeItem(AUTH_TOKEN_KEY) | |
| onLogout?.() | |
| return | |
| } | |
| if (!res.ok) throw new Error('Delete failed') | |
| setCreatives((prev) => prev.filter((c) => c.id !== id)) | |
| setSelected((prev) => prev.filter((x) => x !== id)) | |
| setRefresh((r) => r + 1) | |
| } catch (_) { | |
| setError('Failed to delete') | |
| } finally { | |
| setDeletingId(null) | |
| } | |
| } | |
| async function handleDownloadOne(c) { | |
| if (!c.image_url) return | |
| try { | |
| const res = await fetch(proxyImageUrl(c.image_url)) | |
| const blob = await res.blob() | |
| const a = document.createElement('a') | |
| a.href = URL.createObjectURL(blob) | |
| a.download = `${buildCreativeAssetBaseName({ | |
| productName: c.product_name, | |
| category: c.category, | |
| concept: c.concept_name || 'image', | |
| variation: `v${String(c.creative_id || 1).padStart(2, '0')}`, | |
| date: c.created_at, | |
| })}.png` | |
| a.click() | |
| URL.revokeObjectURL(a.href) | |
| } catch (_) {} | |
| } | |
| async function handleExportToCanva(c) { | |
| if (!c.image_url) return | |
| setExportingToCanvaId(c.id) | |
| try { | |
| const res = await fetch(`${API_BASE}/export-to-canva`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', ...authHeaders() }, | |
| body: JSON.stringify({ | |
| image_url: c.image_url, | |
| title: (c.concept_name || `Amalfa ${c.creative_id}`).trim() || 'Amalfa ad', | |
| }), | |
| }) | |
| const data = await res.json().catch(() => ({})) | |
| if (res.ok && data.edit_url) { | |
| window.open(data.edit_url, '_blank') | |
| } else { | |
| throw new Error(data.detail || data.error || 'Export failed') | |
| } | |
| } catch (e) { | |
| window.alert(e.message || 'Export to Canva failed') | |
| } finally { | |
| setExportingToCanvaId(null) | |
| } | |
| } | |
| function handleConnectCanva() { | |
| fetch(`${API_BASE}/canva/connect`, { headers: authHeaders() }) | |
| .then((r) => { | |
| if (r.status === 401) { | |
| localStorage.removeItem(AUTH_TOKEN_KEY) | |
| onLogout?.() | |
| return null | |
| } | |
| return r.json() | |
| }) | |
| .then((data) => { | |
| if (data?.url) window.location.href = data.url | |
| else if (data) window.alert(data.detail || 'Could not get Connect URL') | |
| }) | |
| .catch(() => window.alert('Connect Canva failed')) | |
| } | |
| async function handleDownloadSelected() { | |
| const toDownload = creatives.filter((c) => c.image_url && selected.includes(c.id)) | |
| if (!toDownload.length) return | |
| setDownloading(true) | |
| try { | |
| const zip = new JSZip() | |
| await Promise.all( | |
| toDownload.map(async (c, i) => { | |
| const res = await fetch(proxyImageUrl(c.image_url)) | |
| const blob = await res.blob() | |
| const id = String(c.creative_id || i + 1).padStart(2, '0') | |
| zip.file( | |
| `${buildCreativeAssetBaseName({ | |
| productName: c.product_name, | |
| category: c.category, | |
| concept: c.concept_name || 'image', | |
| variation: `v${id}`, | |
| date: c.created_at, | |
| })}.png`, | |
| blob | |
| ) | |
| }) | |
| ) | |
| const blob = await zip.generateAsync({ type: 'blob' }) | |
| const a = document.createElement('a') | |
| a.href = URL.createObjectURL(blob) | |
| a.download = 'Amalfa-gallery-selected.zip' | |
| a.click() | |
| URL.revokeObjectURL(a.href) | |
| } catch (_) {} | |
| setDownloading(false) | |
| } | |
| async function handleDeleteSelected() { | |
| const ids = [...selected] | |
| setSelected([]) | |
| for (const id of ids) { | |
| try { | |
| const res = await fetch(`${API_BASE}/gallery/creatives/${id}`, { | |
| method: 'DELETE', | |
| headers: authHeaders(), | |
| }) | |
| if (res.status === 401) { | |
| localStorage.removeItem(AUTH_TOKEN_KEY) | |
| onLogout?.() | |
| return | |
| } | |
| if (res.ok) setCreatives((prev) => prev.filter((c) => c.id !== id)) | |
| } catch (_) {} | |
| } | |
| setRefresh((r) => r + 1) | |
| } | |
| function formatDate(iso) { | |
| if (!iso) return '' | |
| try { | |
| const d = new Date(iso) | |
| return d.toLocaleDateString(undefined, { dateStyle: 'medium' }) | |
| } catch { | |
| return iso | |
| } | |
| } | |
| if (loading) { | |
| return ( | |
| <section className="card gallery-card"> | |
| <p className="gallery-loading">Loading gallery…</p> | |
| </section> | |
| ) | |
| } | |
| return ( | |
| <section className="card gallery-card"> | |
| {lightboxImage && ( | |
| <ImageLightbox | |
| src={lightboxImage.src} | |
| alt={lightboxImage.alt} | |
| onClose={() => setLightboxImage(null)} | |
| /> | |
| )} | |
| {correctionCreative && ( | |
| <CorrectionModal | |
| creative={correctionCreative} | |
| onClose={() => setCorrectionCreative(null)} | |
| onSuccess={() => { setCorrectionCreative(null); setRefresh((r) => r + 1) }} | |
| proxyImageUrl={proxyImageUrl} | |
| API_BASE={API_BASE} | |
| authHeaders={authHeaders} | |
| /> | |
| )} | |
| <h2>Gallery</h2> | |
| <p className="gallery-desc">All your generated ad creatives. Download, correct, or delete from here.</p> | |
| {error && <p className="error">{error}</p>} | |
| {creatives.length === 0 && !error && ( | |
| <div className="gallery-empty"> | |
| <p>No creatives yet.</p> | |
| <Link to="/" className="btn-gallery-to-studio">Go to Studio</Link> | |
| <p className="gallery-empty-hint">Generate ads from a product URL to see them here.</p> | |
| </div> | |
| )} | |
| {creatives.length > 0 && ( | |
| <> | |
| <div className="gallery-toolbar"> | |
| <label className="gallery-select-all"> | |
| <input | |
| type="checkbox" | |
| checked={selected.length === creatives.length && creatives.length > 0} | |
| onChange={(e) => (e.target.checked ? setSelected(creatives.map((c) => c.id)) : setSelected([]))} | |
| /> | |
| Select all | |
| </label> | |
| {canvaStatus.configured && !canvaStatus.connected && ( | |
| <button type="button" className="btn-canva-connect" onClick={handleConnectCanva}> | |
| Connect Canva | |
| </button> | |
| )} | |
| <button | |
| type="button" | |
| className="btn-download-zip" | |
| disabled={downloading || selected.length === 0} | |
| onClick={handleDownloadSelected} | |
| > | |
| {downloading ? 'Preparing…' : `Download selected (${selected.length})`} | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-gallery-delete-selected" | |
| disabled={selected.length === 0} | |
| onClick={handleDeleteSelected} | |
| > | |
| Delete selected | |
| </button> | |
| </div> | |
| <div className="gallery-grid"> | |
| {creatives.map((c) => ( | |
| <div key={c.id} className="gallery-item"> | |
| <div className="gallery-item-img-wrap"> | |
| {c.image_url ? ( | |
| <img | |
| src={proxyImageUrl(c.image_url)} | |
| alt={c.concept_name || `Creative ${c.creative_id}`} | |
| className="gallery-item-img img-expandable" | |
| onClick={(e) => { e.stopPropagation(); setLightboxImage({ src: proxyImageUrl(c.image_url), alt: c.concept_name || `Creative ${c.creative_id}` }) }} | |
| role="button" | |
| tabIndex={0} | |
| onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), setLightboxImage({ src: proxyImageUrl(c.image_url), alt: c.concept_name || `Creative ${c.creative_id}` }))} | |
| /> | |
| ) : ( | |
| <div className="gallery-item-placeholder">No image</div> | |
| )} | |
| <label className="gallery-item-checkbox"> | |
| <input | |
| type="checkbox" | |
| checked={selected.includes(c.id)} | |
| onChange={() => toggleSelect(c.id)} | |
| /> | |
| <span>Select</span> | |
| </label> | |
| </div> | |
| <div className="gallery-item-footer"> | |
| <p className="gallery-item-concept">#{c.creative_id} {c.concept_name || '—'}</p> | |
| {c.product_name && <p className="gallery-item-product">{c.product_name}</p>} | |
| <p className="gallery-item-date">{formatDate(c.created_at)}</p> | |
| <div className="gallery-item-actions"> | |
| {canvaStatus.connected && c.image_url && ( | |
| <button | |
| type="button" | |
| className="btn-export-canva" | |
| disabled={exportingToCanvaId === c.id} | |
| onClick={() => handleExportToCanva(c)} | |
| > | |
| {exportingToCanvaId === c.id ? 'Exporting…' : 'Export to Canva'} | |
| </button> | |
| )} | |
| <button | |
| type="button" | |
| className="btn-gallery-correct" | |
| onClick={() => setCorrectionCreative(c)} | |
| > | |
| Correct | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-gallery-download-one" | |
| disabled={!c.image_url} | |
| onClick={() => handleDownloadOne(c)} | |
| > | |
| Download | |
| </button> | |
| <button | |
| type="button" | |
| className="btn-gallery-delete-one" | |
| disabled={deletingId === c.id} | |
| onClick={() => handleDelete(c.id)} | |
| > | |
| {deletingId === c.id ? 'Deleting…' : 'Delete'} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {totalPages > 1 && ( | |
| <div className="gallery-pagination"> | |
| <button | |
| type="button" | |
| className="btn-gallery-page" | |
| disabled={page === 0 || loading} | |
| onClick={() => { setPage((p) => Math.max(0, p - 1)); setSelected([]); }} | |
| aria-label="Previous page" | |
| > | |
| Previous | |
| </button> | |
| <span className="gallery-pagination-info"> | |
| Page {page + 1} of {totalPages} | |
| {total > 0 && ` (${total} total)`} | |
| </span> | |
| <button | |
| type="button" | |
| className="btn-gallery-page" | |
| disabled={page >= totalPages - 1 || loading} | |
| onClick={() => { setPage((p) => Math.min(totalPages - 1, p + 1)); setSelected([]); }} | |
| aria-label="Next page" | |
| > | |
| Next | |
| </button> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| {canvaStatus.configured && ( | |
| <p className="gallery-canva-link"> | |
| <button | |
| type="button" | |
| className="link-canva-reconnect" | |
| onClick={async () => { | |
| try { | |
| if (canvaStatus.connected) { | |
| await fetch(`${API_BASE}/canva/disconnect`, { method: 'POST', headers: authHeaders() }) | |
| setRefresh((r) => r + 1) | |
| } | |
| handleConnectCanva() | |
| } catch (_) {} | |
| }} | |
| > | |
| {canvaStatus.connected ? 'Reconnect Canva' : 'Connect Canva'} | |
| </button> | |
| </p> | |
| )} | |
| </section> | |
| ) | |
| } | |
| function CorrectionModal({ creative, onClose, onSuccess, proxyImageUrl, API_BASE, authHeaders }) { | |
| const [step, setStep] = useState('input') | |
| const [instructions, setInstructions] = useState('') | |
| const [result, setResult] = useState(null) | |
| const [error, setError] = useState(null) | |
| async function handleSubmit(e) { | |
| e.preventDefault() | |
| const text = (instructions || '').trim() | |
| if (!text) { | |
| setError('Describe what you want to fix.') | |
| return | |
| } | |
| setStep('running') | |
| setError(null) | |
| try { | |
| const res = await fetch(`${API_BASE}/correct`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', ...authHeaders() }, | |
| body: JSON.stringify({ | |
| image_id: creative.id, | |
| image_url: creative.image_url, | |
| user_instructions: text, | |
| }), | |
| }) | |
| if (res.status === 401) return | |
| const data = await res.json().catch(() => ({})) | |
| if (res.ok && data.status === 'success') { | |
| setResult(data) | |
| setStep('done') | |
| } else { | |
| setError(data.detail || data.error || 'Correction failed') | |
| setStep('input') | |
| } | |
| } catch (err) { | |
| setError(err.message || 'Request failed') | |
| setStep('input') | |
| } | |
| } | |
| return ( | |
| <div className="modal-overlay" role="dialog" aria-modal="true"> | |
| <div className="modal-content modal-correction"> | |
| <div className="modal-header"> | |
| <h3>Correct image</h3> | |
| <button type="button" className="modal-close" onClick={onClose} aria-label="Close">×</button> | |
| </div> | |
| {step === 'input' && ( | |
| <form onSubmit={handleSubmit}> | |
| <p className="modal-desc">Describe what you want to fix. The image will be updated accordingly.</p> | |
| <label className="modal-field"> | |
| <span>What to fix</span> | |
| <textarea | |
| value={instructions} | |
| onChange={(e) => setInstructions(e.target.value)} | |
| placeholder="e.g. Strike through the original price" | |
| rows={2} | |
| /> | |
| </label> | |
| {error && <p className="error">{error}</p>} | |
| <div className="modal-actions"> | |
| <button type="button" className="btn-secondary" onClick={onClose}>Cancel</button> | |
| <button type="submit" className="btn-primary" disabled={!(instructions || '').trim()}>Correct</button> | |
| </div> | |
| </form> | |
| )} | |
| {step === 'running' && ( | |
| <div className="modal-body modal-loading"> | |
| <div className="modal-loader-spinner" aria-hidden="true" /> | |
| <p className="modal-loader-text">Correcting image…</p> | |
| <p className="modal-loader-hint">This may take a minute</p> | |
| </div> | |
| )} | |
| {step === 'done' && result?.corrected_image && ( | |
| <div className="modal-body"> | |
| <p className="modal-compare-title">Original vs corrected</p> | |
| <div className="modal-compare"> | |
| <div className="modal-compare-item"> | |
| <span className="modal-compare-label">Original</span> | |
| <img src={proxyImageUrl(creative.image_url)} alt="Original" /> | |
| </div> | |
| <div className="modal-compare-item"> | |
| <span className="modal-compare-label">Corrected</span> | |
| <img | |
| src={ | |
| result.corrected_image.r2_url | |
| ? proxyImageUrl(result.corrected_image.r2_url) | |
| : (result.corrected_image.image_url || '') | |
| } | |
| alt="Corrected" | |
| /> | |
| </div> | |
| </div> | |
| <div className="modal-actions"> | |
| <button type="button" className="btn-secondary" onClick={onClose}>Keep original</button> | |
| <button type="button" className="btn-primary" onClick={() => { onSuccess(); onClose(); }}>Use corrected image</button> | |
| </div> | |
| </div> | |
| )} | |
| {step === 'done' && !result?.corrected_image && ( | |
| <div className="modal-body"> | |
| <p>{result?.error || 'Something went wrong.'}</p> | |
| <button type="button" className="btn-secondary" onClick={() => { setStep('input'); setResult(null); }}>Try again</button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |