Amalfa_Creative_Studio / frontend /src /Variations.jsx
sushilideaclan01's picture
.
030c057
import { useEffect, useState } from 'react'
import JSZip from 'jszip'
import ImageLightbox from './ImageLightbox'
const API_BASE = '/api'
const AUTH_TOKEN_KEY = 'amalfa_auth_token'
function authHeaders() {
const token = typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
if (!token) return {}
return { Authorization: `Bearer ${token}` }
}
function proxyImageUrl(url) {
return url ? `${API_BASE}/proxy-image?url=${encodeURIComponent(url)}` : ''
}
function downloadImage(url, filename) {
const a = document.createElement('a')
a.href = proxyImageUrl(url) || url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
}
async function fetchImageBlob(url) {
if (!url) throw new Error('Missing image URL')
try {
const proxied = await fetch(proxyImageUrl(url))
if (proxied.ok) return await proxied.blob()
} catch (_) {}
const direct = await fetch(url)
if (!direct.ok) throw new Error('Failed to fetch image')
return await direct.blob()
}
export default function Variations({ onLogout }) {
const [imageModels, setImageModels] = useState([])
const [selectedImageModel, setSelectedImageModel] = useState('nano-banana-2')
const [aspectRatio, setAspectRatio] = useState('1:1')
const [variationCount, setVariationCount] = useState(10)
const [userPrompt, setUserPrompt] = useState('')
const [winningImageUrl, setWinningImageUrl] = useState('')
const [winningPreviewUrl, setWinningPreviewUrl] = useState('')
const [uploading, setUploading] = useState(false)
const [generating, setGenerating] = useState(false)
const [progress, setProgress] = useState({ done: 0, total: 0 })
const [results, setResults] = useState([])
const [selected, setSelected] = useState([])
const [downloading, setDownloading] = useState(false)
const [error, setError] = useState(null)
const [lightboxImage, setLightboxImage] = useState(null)
useEffect(() => {
fetch(`${API_BASE}/image-models`, { headers: authHeaders() })
.then((r) => {
if (r.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY)
onLogout?.()
return null
}
return r.ok ? r.json() : null
})
.then((data) => {
if (!data) return
setImageModels(data.models || [])
if (data.default) setSelectedImageModel(data.default)
})
.catch(() => {})
}, [onLogout])
useEffect(() => {
return () => {
if (winningPreviewUrl) URL.revokeObjectURL(winningPreviewUrl)
}
}, [winningPreviewUrl])
async function handleUpload(file) {
if (!file) return
if (winningPreviewUrl) URL.revokeObjectURL(winningPreviewUrl)
setWinningPreviewUrl(URL.createObjectURL(file))
setUploading(true)
setError(null)
try {
const form = new FormData()
form.append('file', file)
const res = await fetch(`${API_BASE}/upload-reference`, {
method: 'POST',
headers: authHeaders(),
body: form,
})
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY)
onLogout?.()
return
}
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.detail || 'Upload failed')
setWinningImageUrl(data.url || '')
} catch (e) {
setError(e.message || 'Upload failed')
} finally {
setUploading(false)
}
}
async function handleGenerate() {
if (!winningImageUrl) {
setError('Upload a winning creative first.')
return
}
setGenerating(true)
setError(null)
setResults([])
setSelected([])
setProgress({ done: 0, total: variationCount })
try {
const res = await fetch(`${API_BASE}/generate-variations/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({
image_url: winningImageUrl,
n: variationCount,
model_key: selectedImageModel,
aspect_ratio: aspectRatio,
user_prompt: userPrompt || undefined,
}),
})
if (res.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY)
onLogout?.()
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || 'Variation generation failed')
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const chunks = buffer.split('\n\n')
buffer = chunks.pop() || ''
for (const chunk of chunks) {
const dataLine = chunk.split('\n').find((l) => l.startsWith('data: '))
if (!dataLine) continue
try {
const payload = JSON.parse(dataLine.slice(6))
if (payload.event === 'result' && payload.data) {
const rowId = `${payload.data.variation_id ?? 'v'}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
setResults((prev) => [...prev, { ...payload.data, rowId }])
setProgress((prev) => ({ ...prev, done: prev.done + 1 }))
} else if (payload.event === 'error') {
setError(payload.message || 'Variation generation failed')
}
} catch (_) {}
}
}
} catch (e) {
setError(e.message || 'Variation generation failed')
} finally {
setGenerating(false)
}
}
function toggleSelect(rowId) {
setSelected((prev) => (prev.includes(rowId) ? prev.filter((id) => id !== rowId) : [...prev, rowId]))
}
async function handleDownloadSelected() {
const toDownload = results.filter((r) => r.image_url && selected.includes(r.rowId))
if (!toDownload.length) return
setDownloading(true)
try {
const zip = new JSZip()
await Promise.all(
toDownload.map(async (item, idx) => {
const blob = await fetchImageBlob(item.image_url)
const id = String(item.variation_id ?? idx + 1).padStart(2, '0')
const slug = (item.concept_name || 'variation')
.replace(/[^a-z0-9]+/gi, '-')
.toLowerCase()
.replace(/^-|-$/g, '') || 'variation'
zip.file(`Amalfa-variation-${id}-${slug}.png`, blob)
})
)
const blob = await zip.generateAsync({ type: 'blob' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'Amalfa-variations-selected.zip'
a.click()
URL.revokeObjectURL(a.href)
} catch (e) {
setError(e.message || 'Failed to prepare ZIP')
} finally {
setDownloading(false)
}
}
return (
<section className="card variations-card">
{lightboxImage && (
<ImageLightbox
src={lightboxImage.src}
alt={lightboxImage.alt}
onClose={() => setLightboxImage(null)}
/>
)}
<h2>Winning Creative Variations</h2>
<p className="gallery-desc">Upload your best-performing creative and generate multiple fresh variations.</p>
{error && <p className="error">{error}</p>}
<div className="variations-controls">
<label>
<span>Winning creative image</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => handleUpload(e.target.files?.[0])}
disabled={uploading || generating}
/>
</label>
<label>
<span>User prompt (optional)</span>
<input
type="text"
value={userPrompt}
onChange={(e) => setUserPrompt(e.target.value)}
placeholder="e.g. Make it moodier, cinematic lighting, more close-up angles"
disabled={generating}
/>
</label>
<label>
<span>How many variations</span>
<input
type="range"
min="1"
max="30"
value={variationCount}
onChange={(e) => setVariationCount(Number(e.target.value))}
disabled={generating}
/>
<strong>{variationCount}</strong>
</label>
<label>
<span>Image model</span>
<select
value={selectedImageModel}
onChange={(e) => setSelectedImageModel(e.target.value)}
disabled={generating}
>
{imageModels.map((m) => (
<option key={m.key} value={m.key}>
{m.label || m.key}
</option>
))}
</select>
</label>
<label>
<span>Aspect ratio</span>
<select value={aspectRatio} onChange={(e) => setAspectRatio(e.target.value)} disabled={generating}>
<option value="1:1">1:1</option>
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
</select>
</label>
</div>
{winningImageUrl && (
<div className="variations-winning-preview">
<img src={winningPreviewUrl || winningImageUrl} alt="Winning creative" />
</div>
)}
<div className="variations-actions">
<button type="button" className="btn-run" disabled={generating || uploading || !winningImageUrl} onClick={handleGenerate}>
{generating ? 'Generating…' : `Generate ${variationCount} Variations`}
</button>
{generating && <span className="gallery-item-date">Progress: {progress.done}/{progress.total}</span>}
</div>
{results.length > 0 && (
<>
<div className="gallery-toolbar">
<label className="gallery-select-all">
<input
type="checkbox"
checked={selected.length === results.length && results.length > 0}
onChange={(e) => (e.target.checked ? setSelected(results.map((r) => r.rowId)) : setSelected([]))}
/>
Select all
</label>
<button
type="button"
className="btn-download-zip"
disabled={downloading || selected.length === 0}
onClick={handleDownloadSelected}
>
{downloading ? 'Preparing…' : `Download selected (${selected.length})`}
</button>
</div>
<div className="gallery-grid">
{results.map((item, idx) => (
<div className="gallery-item" key={item.rowId || `${item.variation_id || idx}-${idx}`}>
<div className="gallery-item-img-wrap">
{item.image_url ? (
<img
src={proxyImageUrl(item.image_url)}
alt={item.concept_name || `Variation ${idx + 1}`}
className="gallery-item-img img-expandable"
onClick={() =>
setLightboxImage({
src: proxyImageUrl(item.image_url),
alt: item.concept_name || `Variation ${idx + 1}`,
})
}
/>
) : (
<div className="gallery-item-placeholder">Failed</div>
)}
<label className="gallery-item-checkbox">
<input
type="checkbox"
checked={selected.includes(item.rowId)}
onChange={() => toggleSelect(item.rowId)}
/>
<span>Select</span>
</label>
</div>
<div className="gallery-item-footer">
<p className="gallery-item-concept">#{item.variation_id || idx + 1} {item.concept_name || 'Variation'}</p>
{item.error ? (
<p className="error">{item.error}</p>
) : (
<button
type="button"
className="btn-gallery-download-one"
onClick={() => downloadImage(item.image_url, `variation-${item.variation_id || idx + 1}.png`)}
>
Download
</button>
)}
</div>
</div>
))}
</div>
</>
)}
</section>
)
}