Files
anil-matcha--open-generativ…/packages/studio/src/components/ImageStudio.jsx
T
2026-07-13 13:02:44 +08:00

1654 lines
69 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { generateImage, generateI2I, uploadFile } from "../muapi.js";
import DrawModal from "./DrawModal.jsx";
import {
t2iModels,
i2iModels,
getAspectRatiosForModel,
getResolutionsForModel,
getQualityFieldForModel,
getAspectRatiosForI2IModel,
getResolutionsForI2IModel,
getQualityFieldForI2IModel,
getMaxImagesForI2IModel,
getEffectsForI2IModel,
getDefaultEffectForI2IModel,
getI2IModelById,
} from "../models.js";
// ─── helpers ────────────────────────────────────────────────────────────────
async function downloadImage(url, filename) {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
window.open(url, "_blank");
}
}
// ─── UploadButton (inline picker) ───────────────────────────────────────────
function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [], label = null }) {
const [panelOpen, setPanelOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedEntries, setSelectedEntries] = useState([]); // [{url, thumbnail}]
const [uploadHistory, setUploadHistory] = useState([]); // [{id, name, url, thumbnail}]
const [lastUploadProgress, setLastUploadProgress] = useState(0);
const fileInputRef = useRef(null);
const panelRef = useRef(null);
const triggerRef = useRef(null);
// Close on outside click
useEffect(() => {
if (!panelOpen) return;
const handler = (e) => {
if (
panelRef.current &&
!panelRef.current.contains(e.target) &&
triggerRef.current &&
!triggerRef.current.contains(e.target)
) {
setPanelOpen(false);
}
};
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, [panelOpen]);
// Sync initialUrls from parent (e.g. restored from localStorage)
useEffect(() => {
if (initialUrls && initialUrls.length > 0) {
// Avoid infinite loops by only updating if URLs actually changed
const currentUrls = selectedEntries.map(e => e.url);
const isSame = initialUrls.length === currentUrls.length && initialUrls.every(u => currentUrls.includes(u));
if (isSame) return;
const newEntries = initialUrls.map(url => ({ url }));
setSelectedEntries(newEntries);
// Also ensure they are in the history panel
setUploadHistory(prev => {
const existingUrls = prev.map(h => h.url);
const missing = initialUrls
.filter(u => !existingUrls.includes(u))
.map(u => ({ id: `restored-${u}`, name: "Restored Image", url: u, progress: 100 }));
return [...missing, ...prev];
});
}
}, [initialUrls]); // eslint-disable-line react-hooks/exhaustive-deps
// When maxImages changes, trim excess selections
useEffect(() => {
if (selectedEntries.length > maxImages) {
const trimmed = selectedEntries.slice(0, maxImages);
setSelectedEntries(trimmed);
if (trimmed.length === 0) onClear?.();
}
if (fileInputRef.current) {
fileInputRef.current.multiple = maxImages > 1;
}
}, [maxImages]); // eslint-disable-line react-hooks/exhaustive-deps
const fireOnSelect = useCallback(
(entries) => {
if (!entries.length) return;
const urls = entries.map((e) => e.url);
onSelect({ url: urls[0], urls, thumbnail: entries[0].url });
},
[onSelect],
);
const handleFileChange = async (e) => {
const files = Array.from(e.target.files);
if (!files.length) return;
e.target.value = "";
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
const tooLarge = files.filter((f) => f.size > MAX_IMAGE_SIZE);
if (tooLarge.length > 0) {
alert(
`The following images are too large (max 10MB): ${tooLarge.map((f) => f.name).join(", ")}`,
);
return;
}
setUploading(true);
try {
const toUpload =
maxImages === 1
? files.slice(0, 1)
: files.slice(0, maxImages - selectedEntries.length || 1);
await Promise.all(
toUpload.map(async (file) => {
const id = Date.now().toString() + Math.random();
// Add a placeholder to history immediately without local preview
const placeholder = { id, name: file.name, url: null, progress: 0 };
setUploadHistory((prev) => [placeholder, ...prev]);
try {
const uploadedUrl = await uploadFile(apiKey, file, (pct) => {
setLastUploadProgress(pct);
setUploadHistory((prev) =>
prev.map((h) => (h.id === id ? { ...h, progress: pct } : h)),
);
});
// Update history with real URL and Mark as 100%
setUploadHistory((prev) =>
prev.map((h) => {
if (h.id === id) {
return { ...h, url: uploadedUrl, progress: 100 };
}
return h;
}),
);
// Auto-select if there's room
if (selectedEntries.length < maxImages) {
const newEntry = { url: uploadedUrl };
setSelectedEntries((prev) => [...prev, newEntry]);
if (maxImages === 1) {
fireOnSelect([newEntry]);
setPanelOpen(false);
}
}
} catch (err) {
console.error("[UploadButton] Upload failed for", file.name, err);
setUploadHistory((prev) => prev.filter((h) => h.id !== id));
throw err;
}
}),
);
} catch (err) {
alert(`Image upload failed: ${err.message}`);
} finally {
setUploading(false);
setLastUploadProgress(0);
}
};
const handleCellClick = (entry) => {
const selIdx = selectedEntries.findIndex((e) => e.url === entry.url);
const isSelected = selIdx !== -1;
const atMax =
maxImages > 1 && !isSelected && selectedEntries.length >= maxImages;
if (atMax) return;
if (maxImages === 1) {
const newSelected = [{ url: entry.url, localUrl: entry.localUrl }];
setSelectedEntries(newSelected);
fireOnSelect(newSelected);
setPanelOpen(false);
} else {
let next;
if (isSelected) {
next = selectedEntries.filter((_, i) => i !== selIdx);
if (next.length === 0) onClear?.();
} else {
next = [
...selectedEntries,
{ url: entry.url, localUrl: entry.localUrl },
];
}
setSelectedEntries(next);
}
};
const handleRemoveFromHistory = (e, entry) => {
e.stopPropagation();
if (entry.localUrl) URL.revokeObjectURL(entry.localUrl);
setUploadHistory((prev) => prev.filter((h) => h.id !== entry.id));
const next = selectedEntries.filter((s) => s.url !== entry.url);
if (next.length !== selectedEntries.length) {
setSelectedEntries(next);
if (next.length === 0) onClear?.();
}
};
const handleDone = (e) => {
e.stopPropagation();
fireOnSelect(selectedEntries);
setPanelOpen(false);
};
const reset = () => {
setSelectedEntries([]);
setPanelOpen(false);
};
// expose reset via ref pattern — parent calls reset() directly
// (handled by parent through uploadedImageUrls state reset)
const isMulti = maxImages > 1;
const count = selectedEntries.length;
const hasSelection = count > 0;
// Trigger icon content
const triggerContent = uploading ? (
<div className="flex flex-col items-center justify-center w-full h-full absolute inset-0 bg-black/80 z-20 backdrop-blur-[2px]">
<svg className="w-8 h-8 -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
className="text-white/10"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="currentColor"
strokeWidth="2"
fill="transparent"
strokeDasharray={88}
strokeDashoffset={88 - (88 * lastUploadProgress) / 100}
className="text-[#22d3ee] transition-all duration-300"
/>
</svg>
<span className="absolute text-[9px] font-black text-[#22d3ee] leading-none">
{lastUploadProgress}%
</span>
</div>
) : label === "Swap Face" ? (
hasSelection ? (
<img src={selectedEntries[0].url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-[10px] font-bold text-white/50">Face</span>
)
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
className="text-white/40 group-hover:text-[#22d3ee] transition-colors"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const defaultLabel = isMulti ? `Add up to ${maxImages} images` : "Reference image";
const triggerTitle = hasSelection
? count > 1
? `${count} of ${maxImages} images selected — click to manage`
: isMulti
? `1 image selected — click to add more (up to ${maxImages})`
: label || "Reference image"
: label || defaultLabel;
return (
<div className="relative">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple={isMulti}
className="hidden"
onChange={handleFileChange}
/>
{/* Trigger button */}
<button
ref={triggerRef}
type="button"
title={triggerTitle}
onClick={(e) => {
e.stopPropagation();
setPanelOpen((o) => !o);
}}
className={`w-12 h-12 shrink-0 rounded-xl border border-dashed transition-all flex items-center justify-center relative overflow-hidden bg-white/[0.02] hover:bg-white/5 group ${
hasSelection
? "border-[#22d3ee]/40 hover:border-[#22d3ee]/60"
: "border-white/10 hover:border-[#22d3ee]/40"
}`}
>
{triggerContent}
</button>
{/* Panel */}
{panelOpen && (
<div
ref={panelRef}
onClick={(e) => e.stopPropagation()}
className="absolute z-50 bottom-[calc(100%+8px)] left-0 bg-[#111] rounded-xl p-3 shadow-4xl border border-white/10 w-96"
>
{/* Header */}
<div className="flex items-center justify-between px-1 pb-3 mb-2 border-b border-white/5">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-bold text-secondary">
Reference Images
</span>
{isMulti && (
<span className="text-[9px] text-muted">
Select up to {maxImages} images
</span>
)}
</div>
<div className="flex items-center gap-2">
{isMulti && hasSelection && (
<button
type="button"
onClick={handleDone}
className="flex items-center gap-1 px-3 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105"
>
Done ({count})
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setPanelOpen(false);
fileInputRef.current?.click();
}}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 hover:bg-primary/20 text-primary rounded-full text-xs font-bold transition-all border border-primary/20"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
{isMulti ? "Upload files" : "Upload new"}
</button>
</div>
</div>
{/* Grid or empty state */}
{uploadHistory.length === 0 ? (
<div className="py-6 flex flex-col items-center gap-2 opacity-40">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-secondary"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span className="text-xs text-secondary">No uploads yet</span>
</div>
) : (
<div className="grid grid-cols-3 gap-2 max-h-56 overflow-y-auto custom-scrollbar pr-0.5">
{uploadHistory.map((entry) => {
const selIdx = selectedEntries.findIndex(
(e) => e.url === entry.url,
);
const isSelected = selIdx !== -1;
const atMax =
isMulti && !isSelected && selectedEntries.length >= maxImages;
return (
<div
key={entry.id}
title={entry.name}
onClick={() => entry.url && handleCellClick(entry)}
className={`relative rounded-xl overflow-hidden border-2 cursor-pointer group/cell aspect-square transition-all ${
isSelected
? "border-primary shadow-glow"
: "border-white/10 hover:border-white/30"
} ${atMax ? "opacity-40 cursor-not-allowed" : ""} ${!entry.url ? "cursor-wait" : ""}`}
>
{entry.url ? (
<img
src={entry.url}
alt={entry.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-white/5 flex flex-col items-center justify-center">
<div className="w-8 h-8 rounded-full border-2 border-primary/30 border-t-primary animate-spin mb-1" />
<span className="text-[10px] font-black text-primary">
{entry.progress}%
</span>
</div>
)}
{/* Hover overlay with delete */}
{entry.url && (
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/cell:opacity-100 transition-opacity flex items-end justify-end p-1">
<button
type="button"
title="Remove from history"
onClick={(e) => handleRemoveFromHistory(e, entry)}
className="w-5 h-5 bg-red-500/80 hover:bg-red-500 rounded-md flex items-center justify-center transition-colors"
>
<svg
width="8"
height="8"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="3"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)}
{/* Selection badge */}
{isSelected && (
<div className="absolute top-1 left-1 min-w-[20px] h-5 bg-primary rounded-full flex items-center justify-center px-1">
{isMulti ? (
<span className="text-[10px] font-black text-black">
{selIdx + 1}
</span>
) : (
<svg
width="9"
height="9"
viewBox="0 0 24 24"
fill="none"
stroke="black"
strokeWidth="4"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Bottom bar for multi-select */}
{isMulti && hasSelection && (
<div className="mt-3 pt-3 border-t border-white/5 flex items-center justify-between">
<span className="text-xs text-secondary">
{count} of {maxImages} selected
</span>
<button
type="button"
onClick={handleDone}
className="px-4 py-1.5 bg-primary text-black rounded-xl text-xs font-black transition-all hover:scale-105"
>
Use Selected
</button>
</div>
)}
</div>
)}
</div>
);
}
// ─── ModelDropdown ────────────────────────────────────────────────────────────
const PROVIDER_LOGOS = {
openai: "https://cdn.muapi.ai/models/openai.png",
google: "https://cdn.muapi.ai/models/gemini.png",
kling: "https://cdn.muapi.ai/models/kling.png",
alibaba: "https://cdn.muapi.ai/models/alibaba.png",
bytedance: "https://cdn.muapi.ai/models/bytedance.png",
blackforest: "https://cdn.muapi.ai/models/bfl.png",
minimax: "https://cdn.muapi.ai/models/minimax.png",
suno: "https://cdn.muapi.ai/models/suno.png",
anthropic: "https://cdn.muapi.ai/models/claude.png",
meshy: "https://cdn.muapi.ai/models/meshy-3.png",
tripo3d: "https://cdn.muapi.ai/models/tripo3d.png",
grok: "https://cdn.muapi.ai/models/xai.png",
muapi: "https://cdn.muapi.ai/models/muapi.png",
midjourney: "https://cdn.muapi.ai/models/midjourney.png",
vidu: "https://cdn.muapi.ai/models/vidu.png",
runway: "https://cdn.muapi.ai/models/runway.png",
luma: "https://cdn.muapi.ai/models/luma.png",
ideogram: "https://cdn.muapi.ai/models/ideogram.png",
leonardoai: "https://cdn.muapi.ai/models/leonardoai.png",
hunyuan: "https://cdn.muapi.ai/models/hunyuan.png",
hidream: "https://cdn.muapi.ai/models/hidream.png",
lightricks: "https://cdn.muapi.ai/models/lightricks.png",
pixverse: "https://cdn.muapi.ai/models/pixverse.png",
reve: "https://cdn.muapi.ai/models/reve.png",
stability: "https://cdn.muapi.ai/models/stability.png"
};
const invertLogos = ['openai', 'blackforest', 'runway', 'ideogram', 'lightricks', 'grok'];
function ModelDropdown({ models, selectedModel, onSelect, onClose }) {
const [search, setSearch] = useState("");
const [selectedProvider, setSelectedProvider] = useState("all");
const getProviderStyle = (provider) => {
switch (provider) {
case "grok":
return { text: "xI", bg: "bg-orange-500/10 text-orange-400 border-orange-500/25" };
case "openai":
return { text: "O", bg: "bg-emerald-500/10 text-emerald-400 border-emerald-500/25" };
case "google":
return { text: "G", bg: "bg-blue-500/10 text-blue-400 border-blue-500/25" };
case "blackforest":
return { text: "BF", bg: "bg-amber-500/10 text-amber-400 border-amber-500/25" };
case "bytedance":
return { text: "BD", bg: "bg-purple-500/10 text-purple-400 border-purple-500/25" };
case "midjourney":
return { text: "MJ", bg: "bg-indigo-500/10 text-indigo-400 border-indigo-500/25" };
case "kling":
return { text: "KL", bg: "bg-rose-500/10 text-rose-400 border-rose-500/25" };
case "vidu":
return { text: "VD", bg: "bg-cyan-500/10 text-cyan-400 border-cyan-500/25" };
case "minimax":
return { text: "MX", bg: "bg-pink-500/10 text-pink-400 border-pink-500/25" };
case "ideogram":
return { text: "ID", bg: "bg-yellow-500/10 text-yellow-400 border-yellow-500/25" };
case "luma":
return { text: "LM", bg: "bg-teal-500/10 text-teal-400 border-teal-500/25" };
case "alibaba":
return { text: "AL", bg: "bg-sky-500/10 text-sky-400 border-sky-500/25" };
case "leonardoai":
return { text: "LE", bg: "bg-violet-500/10 text-violet-400 border-violet-500/25" };
case "stability":
return { text: "SD", bg: "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/25" };
default:
const name = provider ? provider.toUpperCase() : "AI";
return { text: name.substring(0, 2), bg: "bg-primary/10 text-primary border-primary/25" };
}
};
// Dynamically compute list of providers from the input models list
const availableProviders = [];
const seenProviders = new Set();
models.forEach(m => {
const pId = m.provider || 'muapi';
const pName = m.provider_name || 'Muapi';
if (!seenProviders.has(pId)) {
seenProviders.add(pId);
availableProviders.push({ id: pId, name: pName });
}
});
const filtered = models.filter((m) => {
// 1. Filter by provider tab
if (selectedProvider !== "all") {
const pId = m.provider || 'muapi';
if (pId !== selectedProvider) return false;
}
// 2. Filter by search query
const query = search.toLowerCase();
return (
m.name.toLowerCase().includes(query) ||
m.id.toLowerCase().includes(query)
);
});
const invertLogos = ['openai', 'blackforest', 'runway', 'ideogram', 'lightricks', 'grok'];
return (
<div className="flex gap-4 h-full max-h-[60vh] min-h-[350px] overflow-x-hidden">
{/* Left Sidebar: Provider tabs */}
<div className="flex flex-col gap-2.5 items-center pr-3 border-r border-white/5 shrink-0 select-none overflow-y-auto custom-scrollbar w-12 pt-0.5">
<button
type="button"
onClick={() => setSelectedProvider("all")}
className={`w-8.5 h-8.5 rounded-full flex items-center justify-center border transition-all flex-shrink-0 cursor-pointer ${
selectedProvider === "all"
? "bg-white/10 text-yellow-400 border-yellow-500/30 shadow-md scale-105"
: "bg-white/[0.02] text-white/50 border-white/[0.03] hover:bg-white/5 hover:text-white"
}`}
title="All Providers"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill={selectedProvider === "all" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
{availableProviders.map(p => {
const style = getProviderStyle(p.id);
const isSelected = selectedProvider === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setSelectedProvider(p.id)}
className={`w-8 h-8 flex-shrink-0 rounded-full flex items-center justify-center font-black text-[10px] border transition-all flex-shrink-0 cursor-pointer overflow-hidden ${
isSelected
? `${style.bg} border-white/25 scale-105 shadow-md`
: "bg-white/[0.02] text-white/40 border-white/[0.02] hover:bg-white/5 hover:text-white/80"
}`}
title={p.name}
>
{PROVIDER_LOGOS[p.id] ? (
<img
src={PROVIDER_LOGOS[p.id]}
alt={p.name}
className={`w-full h-full rounded-full object-contain ${invertLogos.includes(p.id) ? "invert" : ""}`}
/>
) : (
style.text
)}
</button>
);
})}
</div>
{/* Right Pane: Search input + Models list */}
<div className="flex-1 flex flex-col gap-2 min-w-0">
<div className="border-b border-white/5 shrink-0 pb-2">
<div className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-2 border border-white/5 focus-within:border-primary/50 transition-colors">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search models..."
value={search}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setSearch(e.target.value)}
className="bg-transparent border-none text-xs text-white focus:ring-0 w-full p-0 focus:outline-none"
/>
</div>
</div>
<div className="text-xs font-semibold text-secondary py-1 shrink-0 flex items-center justify-between">
<span>Available models</span>
{selectedProvider !== "all" && (
<span className="text-[10px] bg-white/5 px-2 py-0.5 rounded text-white/60">
{availableProviders.find(p => p.id === selectedProvider)?.name || selectedProvider}
</span>
)}
</div>
<div className="flex flex-col gap-1.5 overflow-y-auto custom-scrollbar pr-1 pb-2 flex-1">
{filtered.length === 0 ? (
<div className="text-xs text-white/30 text-center py-6">
No models found
</div>
) : (
filtered.map((m) => (
<div
key={m.id}
onClick={(e) => {
e.stopPropagation();
onSelect(m);
onClose();
}}
className={`flex items-center justify-between p-3 hover:bg-white/5 rounded-lg cursor-pointer transition-all border border-transparent hover:border-white/5 ${
selectedModel === m.id ? "bg-white/5 border-white/5" : ""
}`}
>
<div className="flex items-center gap-3">
{PROVIDER_LOGOS[m.provider] ? (
<div className="w-8 h-8 rounded-full border border-white/5 overflow-hidden shrink-0 flex items-center justify-center bg-white/[0.02]">
<img
src={PROVIDER_LOGOS[m.provider]}
alt={m.provider_name}
className={`w-full h-full object-contain p-1 ${invertLogos.includes(m.provider) ? "invert" : ""}`}
/>
</div>
) : (
<div
className={`w-8.5 h-8.5 ${
m.family === "kontext"
? "bg-blue-500/10 text-blue-400 border-blue-500/10"
: m.family === "effects"
? "bg-purple-500/10 text-purple-400 border-purple-500/10"
: "bg-primary/10 text-primary border-primary/10"
} border rounded-full flex items-center justify-center font-bold text-xs shadow-inner uppercase`}
>
{m.name.charAt(0)}
</div>
)}
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-bold text-white tracking-tight truncate">
{m.name}
</span>
{selectedProvider === "all" && m.provider_name && (
<span className="text-[9px] text-white/40">
{m.provider_name}
</span>
)}
</div>
</div>
{selectedModel === m.id && (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="#22d3ee"
strokeWidth="4"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
))
)}
</div>
</div>
</div>
);
}
// ─── SimpleDropdown ───────────────────────────────────────────────────────────
function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
return (
<>
<div className="text-xs font-semibold text-white/30 uppercase tracking-wider pb-2 border-b border-white/[0.05] mb-2 px-1">
{title}
</div>
<div className="flex flex-col gap-1">
{options.map((opt) => (
<div
key={opt}
onClick={(e) => {
e.stopPropagation();
onSelect(opt);
onClose();
}}
className="flex items-center justify-between p-2.5 px-3 hover:bg-[#22d3ee]/10 hover:text-white rounded-xl cursor-pointer transition-all group"
>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{opt}
</span>
{selected === opt && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#22d3ee"
strokeWidth="4.5"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
))}
</div>
</>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function ImageStudio({
apiKey,
onGenerationComplete,
historyItems,
droppedFiles,
onFilesHandled,
}) {
const PERSIST_KEY = "hg_image_studio_persistent";
// ── Model / mode state ──────────────────────────────────────────────────
const [imageMode, setImageMode] = useState(false); // false=t2i, true=i2i
const [selectedModelId, setSelectedModelId] = useState(t2iModels[0].id);
const [selectedModelName, setSelectedModelName] = useState(t2iModels[0].name);
const [selectedAr, setSelectedAr] = useState(
t2iModels[0].inputs?.aspect_ratio?.default || "1:1",
);
const [selectedQuality, setSelectedQuality] = useState(() => {
const resolutions = getResolutionsForModel(t2iModels[0].id);
return resolutions[0] || null;
});
const [selectedEffect, setSelectedEffect] = useState("");
const [maxImages, setMaxImages] = useState(1);
// ── Prompt / upload state ───────────────────────────────────────────────
const [prompt, setPrompt] = useState("");
const [uploadedImageUrls, setUploadedImageUrls] = useState([]);
const [swapImageUrl, setSwapImageUrl] = useState(null);
// ── UI state ────────────────────────────────────────────────────────────
const [dropdownOpen, setDropdownOpen] = useState(null); // 'model' | 'ar' | 'quality' | null
const [generating, setGenerating] = useState(false);
const [generateError, setGenerateError] = useState(null);
const [fullscreenUrl, setFullscreenUrl] = useState(null);
const [isDrawModalOpen, setIsDrawModalOpen] = useState(false);
// ── Canvas / history state ──────────────────────────────────────────────
const [currentImageUrl, setCurrentImageUrl] = useState(null);
const [activeHistoryIdx, setActiveHistoryIdx] = useState(0);
const [batchSize, setBatchSize] = useState(1);
const [localHistory, setLocalHistory] = useState([]); // [{id,url,prompt,model,aspect_ratio,timestamp}]
// Use prop history if provided, otherwise local
const history = historyItems ?? localHistory;
// ── Refs ────────────────────────────────────────────────────────────────
const textareaRef = useRef(null);
const dropdownRef = useRef(null);
const uploadPickerResetRef = useRef(null); // not used directly — managed via key
// ── Close dropdown on outside click ─────────────────────────────────────
useEffect(() => {
if (!dropdownOpen) return;
const handler = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setDropdownOpen(null);
}
};
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, [dropdownOpen]);
// ── Persistence: Load ────────────────────────────────────────────────────
useEffect(() => {
try {
const stored = localStorage.getItem(PERSIST_KEY);
if (stored) {
const data = JSON.parse(stored);
if (data.imageMode !== undefined) setImageMode(data.imageMode);
if (data.selectedModelId) setSelectedModelId(data.selectedModelId);
if (data.selectedModelName) setSelectedModelName(data.selectedModelName);
if (data.selectedAr) setSelectedAr(data.selectedAr);
if (data.selectedQuality) setSelectedQuality(data.selectedQuality);
if (data.selectedEffect) setSelectedEffect(data.selectedEffect);
if (data.maxImages) setMaxImages(data.maxImages);
if (data.prompt) setPrompt(data.prompt);
if (data.uploadedImageUrls) setUploadedImageUrls(data.uploadedImageUrls);
if (data.batchSize) setBatchSize(data.batchSize);
if (data.localHistory) setLocalHistory(data.localHistory);
}
} catch (err) {
console.warn("Failed to load ImageStudio persistence:", err);
}
}, []);
// ── Adjust height on load ────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
handleTextareaInput();
}, 150);
return () => clearTimeout(timer);
}, []);
// ── Persistence: Save ────────────────────────────────────────────────────
useEffect(() => {
const timer = setTimeout(() => {
try {
const state = {
imageMode,
selectedModelId,
selectedModelName,
selectedAr,
selectedQuality,
selectedEffect,
maxImages,
prompt,
uploadedImageUrls,
batchSize,
localHistory,
};
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
} catch (err) {
console.warn("Failed to save ImageStudio persistence:", err);
}
}, 500); // 500ms debounce
return () => clearTimeout(timer);
}, [
imageMode,
selectedModelId,
selectedModelName,
selectedAr,
selectedQuality,
selectedEffect,
maxImages,
prompt,
uploadedImageUrls,
batchSize,
localHistory,
]);
const processDroppedImages = async (files) => {
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
const tooLarge = files.filter((f) => f.size > MAX_IMAGE_SIZE);
if (tooLarge.length > 0) {
alert(
`The following images are too large (max 10MB): ${tooLarge.map((f) => f.name).join(", ")}`
);
return;
}
setGenerating(true); // Show as generating/busy
try {
const toUpload =
maxImages === 1 ? files.slice(0, 1) : files.slice(0, maxImages);
const urls = await Promise.all(
toUpload.map(async (file) => {
try {
return await uploadFile(apiKey, file);
} catch (err) {
console.error(
"[ImageStudio] Drop upload failed for",
file.name,
err
);
throw err;
}
})
);
handleUploadSelect({ urls });
} catch (err) {
alert(`Image upload failed: ${err.message}`);
} finally {
setGenerating(false);
}
};
// ── Handle Dropped Files ────────────────────────────────────────────────
useEffect(() => {
if (droppedFiles && droppedFiles.length > 0) {
const imageFiles = droppedFiles.filter(f => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
processDroppedImages(imageFiles);
}
onFilesHandled?.();
}
}, [droppedFiles, onFilesHandled, processDroppedImages]);
// ── Derived: current model lists & helpers ───────────────────────────────
const currentModels = imageMode ? i2iModels : t2iModels;
const currentAspectRatios = imageMode
? getAspectRatiosForI2IModel(selectedModelId)
: getAspectRatiosForModel(selectedModelId);
const currentResolutions = imageMode
? getResolutionsForI2IModel(selectedModelId)
: getResolutionsForModel(selectedModelId);
const currentQualityField = imageMode
? getQualityFieldForI2IModel(selectedModelId)
: getQualityFieldForModel(selectedModelId);
const showQualityBtn = currentResolutions.length > 0;
const currentEffects = imageMode ? getEffectsForI2IModel(selectedModelId) : [];
const showEffectBtn = currentEffects.length > 0;
// ── Textarea auto-resize ─────────────────────────────────────────────────
const handleTextareaInput = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const maxHeight = window.innerWidth < 768 ? 150 : 250;
el.style.height = Math.min(el.scrollHeight, maxHeight) + "px";
};
// ── Upload picker callbacks ──────────────────────────────────────────────
const handleUploadSelect = useCallback(
({ url, urls }) => {
const newUrls = urls || [url];
setUploadedImageUrls(newUrls);
if (!imageMode) {
const firstI2I = i2iModels[0];
const ars = getAspectRatiosForI2IModel(firstI2I.id);
const resolutions = getResolutionsForI2IModel(firstI2I.id);
const effects = getEffectsForI2IModel(firstI2I.id);
setImageMode(true);
setSelectedModelId(firstI2I.id);
setSelectedModelName(firstI2I.name);
setSelectedAr(ars[0] || "1:1");
setSelectedQuality(resolutions[0] || null);
setSelectedEffect(effects.length > 0 ? (getDefaultEffectForI2IModel(firstI2I.id) || effects[0]) : "");
setMaxImages(getMaxImagesForI2IModel(firstI2I.id));
}
},
[imageMode],
);
const handleUploadClear = useCallback(() => {
setUploadedImageUrls([]);
setImageMode(false);
const firstT2I = t2iModels[0];
const ars = getAspectRatiosForModel(firstT2I.id);
const resolutions = getResolutionsForModel(firstT2I.id);
setSelectedModelId(firstT2I.id);
setSelectedModelName(firstT2I.name);
setSelectedAr(ars[0] || "1:1");
setSelectedQuality(resolutions[0] || null);
setSelectedEffect("");
setMaxImages(1);
}, []);
// ── Model selection ──────────────────────────────────────────────────────
const handleModelSelect = (m) => {
const ars = imageMode
? getAspectRatiosForI2IModel(m.id)
: getAspectRatiosForModel(m.id);
const resolutions = imageMode
? getResolutionsForI2IModel(m.id)
: getResolutionsForModel(m.id);
setSelectedModelId(m.id);
setSelectedModelName(m.name);
setSelectedAr(ars[0] || "1:1");
setSelectedQuality(resolutions[0] || null);
setSwapImageUrl(null);
if (imageMode) {
setMaxImages(getMaxImagesForI2IModel(m.id));
const effects = getEffectsForI2IModel(m.id);
setSelectedEffect(effects.length > 0 ? (getDefaultEffectForI2IModel(m.id) || effects[0]) : "");
} else {
setSelectedEffect("");
}
};
// ── History helpers ──────────────────────────────────────────────────────
const addToHistory = useCallback(
(entry) => {
if (!historyItems) {
setLocalHistory((prev) => [entry, ...prev.slice(0, 49)]);
}
setActiveHistoryIdx(0);
setCurrentImageUrl(entry.url);
},
[historyItems],
);
// ── View state ─────────────────────────────────────
const resetToPrompt = () => {
setCurrentImageUrl(null);
setPrompt("");
setUploadedImageUrls([]);
setImageMode(false);
const firstT2I = t2iModels[0];
const ars = getAspectRatiosForModel(firstT2I.id);
const resolutions = getResolutionsForModel(firstT2I.id);
setSelectedModelId(firstT2I.id);
setSelectedModelName(firstT2I.name);
setSelectedAr(ars[0] || "1:1");
setSelectedQuality(resolutions[0] || null);
setSelectedEffect("");
setMaxImages(1);
};
// ── Generation ───────────────────────────────────────────────────────────
const handleGenerate = async () => {
if (generating) return;
if (imageMode) {
if (uploadedImageUrls.length === 0) {
alert("Please upload a reference image first.");
return;
}
const modelInfo = getI2IModelById(selectedModelId);
if (modelInfo?.swapField && !swapImageUrl) {
alert("Please upload a swap face image.");
return;
}
} else {
if (!prompt.trim()) {
alert("Please enter a prompt to generate an image.");
return;
}
}
setGenerating(true);
setGenerateError(null);
try {
const results = await Promise.all(
Array.from({ length: batchSize }).map(async () => {
if (imageMode) {
const genParams = {
model: selectedModelId,
images_list: uploadedImageUrls,
image_url: uploadedImageUrls[0],
aspect_ratio: selectedAr,
};
if (swapImageUrl) genParams.swap_url = swapImageUrl;
if (prompt.trim()) genParams.prompt = prompt.trim();
if (currentQualityField && selectedQuality) {
genParams[currentQualityField] = selectedQuality;
}
if (showEffectBtn && selectedEffect) genParams.name = selectedEffect;
return await generateI2I(apiKey, genParams);
} else {
const genParams = {
model: selectedModelId,
prompt: prompt.trim(),
aspect_ratio: selectedAr,
};
if (currentQualityField && selectedQuality) {
genParams[currentQualityField] = selectedQuality;
}
return await generateImage(apiKey, genParams);
}
})
);
results.forEach((res) => {
if (res && res.url) {
const entry = {
id: res.id || Math.random().toString(36).substring(7),
url: res.url,
prompt: prompt.trim(),
model: selectedModelId,
aspect_ratio: selectedAr,
timestamp: new Date().toISOString(),
};
addToHistory(entry);
onGenerationComplete?.({
url: res.url,
model: selectedModelId,
prompt: prompt.trim(),
type: "image",
});
}
});
} catch (e) {
console.error("[ImageStudio] Generation failed:", e);
setGenerateError(e.message.slice(0, 80));
setTimeout(() => setGenerateError(null), 4000);
} finally {
setGenerating(false);
}
};
const placeholderText =
uploadedImageUrls.length > 1
? `${uploadedImageUrls.length} images selected — describe the transformation (optional)`
: imageMode
? "Describe how to transform this image (optional)"
: "Describe the image you want to create";
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-hidden">
{/* ── CENTRAL GALLERY AREA ── */}
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
{history.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 w-full pt-4 animate-fade-in-up">
{history.map((entry, idx) => (
<div
key={entry.id || idx}
className="relative group rounded-lg overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
>
<img
src={entry.url}
alt={entry.prompt?.substring(0, 30) || "Generated image"}
className="w-full aspect-square object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setFullscreenUrl(entry.url)}
/>
{/* Overlay actions */}
<div className="absolute top-2 right-2 flex flex-col gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
title="Fullscreen"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(entry.url);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
</button>
<button
type="button"
title="Download"
onClick={(e) => {
e.stopPropagation();
downloadImage(entry.url, `muapi-${entry.id || idx}.jpg`);
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-primary hover:text-black transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</button>
<button
type="button"
title="Delete"
onClick={(e) => {
e.stopPropagation();
if (confirm("Are you sure you want to delete this generated item?")) {
setLocalHistory(prev => prev.filter((_, i) => i !== idx));
}
}}
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-red-400 hover:bg-red-500 hover:text-white transition-all border border-white/10"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
{/* Prompt & Details */}
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
<p className="text-white/70 text-xs line-clamp-3 leading-relaxed" title={entry.prompt}>
{entry.prompt || "No prompt provided"}
</p>
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] font-bold text-primary px-2 py-0.5 bg-primary/10 rounded border border-primary/20">
{entry.model?.replace("-", " ")}
</span>
<span className="text-[10px] text-white/40">{entry.aspect_ratio}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up transition-all duration-700 min-h-[50vh]">
{/* Overlapping floating cards */}
<div className="flex items-center justify-center gap-1.5 md:gap-3 mb-10 select-none scale-90 sm:scale-100">
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/sdxl-image.avif"
alt="Creative asset 1"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl -rotate-[4deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/chroma-image.avif"
alt="Creative asset 2"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-18 sm:w-24 sm:h-24 rounded-full border border-white/10 shadow-2xl rotate-[6deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/neta-lumina.avif"
alt="Creative asset 3"
className="w-full h-full object-cover"
/>
</div>
<div className="w-18 h-22 sm:w-24 sm:h-28 rounded-2xl border border-white/10 shadow-2xl rotate-[12deg] transform hover:rotate-0 hover:scale-110 hover:z-20 transition-all duration-300 overflow-hidden bg-white/[0.01] -ml-3 sm:-ml-4 flex-shrink-0">
<img
src="https://d3adwkbyhxyrtq.cloudfront.net/webassets/videomodels/perfect-pony-xl.avif"
alt="Creative asset 4"
className="w-full h-full object-cover"
/>
</div>
</div>
<h1 className="text-2xl sm:text-4xl md:text-5xl font-extrabold tracking-tight mb-4 text-center px-4 flex flex-col items-center">
<span className="text-white font-black uppercase text-xl sm:text-3xl tracking-wide mb-1 opacity-90">START CREATING WITH</span>
<span className="text-[#22d3ee] font-black uppercase text-2xl sm:text-4xl sm:mt-1 tracking-tight">
{selectedModelName}
</span>
</h1>
<p className="text-white/40 text-xs sm:text-sm font-medium tracking-wide text-center max-w-lg leading-relaxed px-4">
Describe a scene, character, mood, or style and watch it come to life
</p>
</div>
)}
</div>
{/* ── BOTTOM PROMPT BAR ── */}
<div
className="absolute bottom-4 w-full max-w-[95%] lg:max-w-4xl z-40 animate-fade-in-up"
style={{ animationDelay: "0.2s" }}
>
<div className="w-full bg-gradient-to-b from-[#18181c]/90 via-[#0f0f12]/90 to-[#0c0c0e]/95 backdrop-blur-2xl rounded-[2rem] border border-white/[0.08] p-4 flex flex-col gap-3 shadow-[0_15px_50px_rgba(0,0,0,0.8)]">
{/* Top row: upload picker + textarea */}
<div className="flex flex-col gap-3">
{/* Inline list of uploaded files */}
<div className="flex items-center gap-2.5 flex-wrap">
{uploadedImageUrls && uploadedImageUrls.length > 0 && uploadedImageUrls.map((url, idx) => (
<div key={idx} className="relative w-12 h-12 rounded-xl border border-white/10 overflow-hidden shadow-md group">
<img src={url} alt="" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => {
const next = uploadedImageUrls.filter((_, i) => i !== idx);
setUploadedImageUrls(next);
if (next.length === 0) handleUploadClear();
}}
className="absolute top-0.5 right-0.5 w-4 h-4 bg-black/60 hover:bg-black rounded-full flex items-center justify-center text-white/85 hover:text-white text-[8px] border border-white/5"
>
×
</button>
</div>
))}
{/* Main Upload Trigger */}
{uploadedImageUrls.length < maxImages && (
<UploadButton
apiKey={apiKey}
maxImages={maxImages}
onSelect={handleUploadSelect}
onClear={handleUploadClear}
initialUrls={uploadedImageUrls}
/>
)}
{/* Swap Image Upload Trigger */}
{imageMode && getI2IModelById(selectedModelId)?.swapField && (
<UploadButton
apiKey={apiKey}
maxImages={1}
onSelect={({ urls }) => setSwapImageUrl(urls[0] || null)}
onClear={() => setSwapImageUrl(null)}
initialUrls={swapImageUrl ? [swapImageUrl] : []}
label="Swap Face"
/>
)}
</div>
{/* Input prompt text area */}
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onInput={handleTextareaInput}
placeholder={placeholderText}
rows={1}
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/20 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar"
/>
</div>
{/* Bottom row: controls + generate */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-white/[0.03] relative">
{/* Left controls */}
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
{/* Model button */}
<div className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen((o) => (o === "model" ? null : "model"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<div className="w-4 h-4 rounded overflow-hidden shrink-0 flex items-center justify-center bg-white/5">
{(() => {
const selectedModelObj = currentModels.find(m => m.id === selectedModelId);
const selectedModelProvider = selectedModelObj?.provider || 'muapi';
return PROVIDER_LOGOS[selectedModelProvider] ? (
<img
src={PROVIDER_LOGOS[selectedModelProvider]}
alt=""
className={`w-full h-full object-contain ${invertLogos.includes(selectedModelProvider) ? "invert" : ""}`}
/>
) : (
<span className="text-[9px] font-bold text-black uppercase">G</span>
);
})()}
</div>
<span className="text-xs font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{selectedModelName}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{dropdownOpen === "model" && (
<div
ref={dropdownRef}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl w-[calc(100vw-2rem)] md:w-[480px] max-w-md md:max-w-none"
>
<ModelDropdown
models={currentModels}
selectedModel={selectedModelId}
onSelect={handleModelSelect}
onClose={() => setDropdownOpen(null)}
/>
</div>
)}
</div>
{/* Aspect ratio button */}
<div className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen((o) => (o === "ar" ? null : "ar"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{selectedAr}
</span>
</button>
{dropdownOpen === "ar" && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]"
>
<SimpleDropdown
title="Aspect Ratio"
options={currentAspectRatios}
selected={selectedAr}
onSelect={(val) => setSelectedAr(val)}
onClose={() => setDropdownOpen(null)}
/>
</div>
)}
</div>
{/* Quality/resolution button (represented as Diamond icon) */}
{showQualityBtn && (
<div className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen((o) => (o === "quality" ? null : "quality"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-40 text-white">
<polygon points="12 2 22 12 12 22 2 12" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
{selectedQuality || currentResolutions[0]}
</span>
</button>
{dropdownOpen === "quality" && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[160px]"
>
<SimpleDropdown
title="Resolution"
options={currentResolutions}
selected={selectedQuality}
onSelect={(val) => setSelectedQuality(val)}
onClose={() => setDropdownOpen(null)}
/>
</div>
)}
</div>
)}
{/* Effect type button */}
{showEffectBtn && (
<div className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen((o) => (o === "effect" ? null : "effect"));
}}
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
<path d="M5 3l14 9-14 9V3z" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors max-w-[140px] truncate">
{selectedEffect || "Effect"}
</span>
</button>
{dropdownOpen === "effect" && (
<div
onClick={(e) => e.stopPropagation()}
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0c0c0f]/95 rounded-xl p-3.5 max-h-[40vh] overflow-y-auto custom-scrollbar shadow-[0_10px_40px_rgba(0,0,0,0.8)] border border-white/[0.08] backdrop-blur-2xl min-w-[200px]"
>
<SimpleDropdown
title="Effect Type"
options={currentEffects}
selected={selectedEffect}
onSelect={(val) => setSelectedEffect(val)}
onClose={() => setDropdownOpen(null)}
/>
</div>
)}
</div>
)}
{/* Batch size stepper */}
<div className="h-[34px] flex items-center gap-2 bg-[#16161a]/60 rounded-md px-2.5 border border-white/[0.06] shadow-inner select-none">
<button
type="button"
onClick={() => setBatchSize(prev => Math.max(1, prev - 1))}
className="text-white/40 hover:text-white/80 font-extrabold text-xs transition-colors px-1"
>
-
</button>
<span className="text-[11px] font-black text-white/70 min-w-[24px] text-center">
{batchSize}/4
</span>
<button
type="button"
onClick={() => setBatchSize(prev => Math.min(4, prev + 1))}
className="text-white/40 hover:text-white/80 font-extrabold text-xs transition-colors px-1"
>
+
</button>
</div>
{/* Draw button */}
<button
type="button"
className="h-[34px] flex items-center gap-2 px-3.5 bg-[#16161a]/60 hover:bg-[#202026]/80 rounded-md transition-all border border-white/[0.06] group whitespace-nowrap shadow-inner"
onClick={() => setIsDrawModalOpen(true)}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="opacity-40 text-white group-hover:text-[#22d3ee] transition-colors">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#22d3ee] transition-colors">
Draw
</span>
</button>
</div>
{/* Generate button */}
<button
type="button"
onClick={handleGenerate}
disabled={generating}
className="bg-[#22d3ee] text-black px-7 py-3 rounded-full font-black text-sm hover:opacity-95 hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 w-full sm:w-auto shadow-lg shadow-[#22d3ee]/20 hover:shadow-[#22d3ee]/35 border border-[#22d3ee]/10 z-10"
>
{generating ? (
<>
<span className="animate-spin inline-block text-black"></span>
Generating...
</>
) : generateError ? (
`Error: ${generateError}`
) : (
<>
<span>Generate {batchSize}</span>
</>
)}
</button>
</div>
</div>
</div>
{/* ── FULLSCREEN IMAGE MODAL ── */}
{fullscreenUrl && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm animate-fade-in"
onClick={() => setFullscreenUrl(null)}
>
<button
type="button"
className="absolute top-6 right-6 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors border border-white/10"
onClick={(e) => {
e.stopPropagation();
setFullscreenUrl(null);
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<img
src={fullscreenUrl}
alt="Fullscreen Preview"
className="max-w-[95vw] max-h-[95vh] rounded-2xl shadow-2xl object-contain animate-scale-up"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{/* ── DRAW CANVAS MODAL ── */}
<DrawModal
isOpen={isDrawModalOpen}
onClose={() => setIsDrawModalOpen(false)}
apiKey={apiKey}
batchSize={1}
onAddHistoryItem={addToHistory}
/>
</div>
);
}