sushilideaclan01's picture
nomeclature fix
e062ed0
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">&times;</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>
)
}