feat: finalize studio persistence and multiple reference image upload UI
This commit is contained in:
parent
b924f0caf8
commit
91ce11df6f
9 changed files with 3765 additions and 2741 deletions
|
|
@ -2,21 +2,32 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #050505;
|
background: #050505;
|
||||||
color: white;
|
color: white;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-primary: #d9ff00;
|
--color-primary: #d9ff00;
|
||||||
--bg-app: #050505;
|
--bg-app: #030303;
|
||||||
--bg-panel: #0a0a0a;
|
--bg-panel: #0a0a0a;
|
||||||
--bg-card: #111111;
|
--bg-card: #111111;
|
||||||
--border-color: rgba(255,255,255,0.08);
|
--border-color: rgba(255,255,255,0.05);
|
||||||
--border-radius-xl: 1rem;
|
--border-radius-xl: 0.75rem;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||||
|
|
@ -24,7 +35,7 @@ body {
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||||
|
|
||||||
@keyframes fade-in-up {
|
@keyframes fade-in-up {
|
||||||
from { opacity: 0; transform: translateY(16px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
.animate-fade-in-up { animation: fade-in-up 0.4s ease forwards; }
|
.animate-fade-in-up { animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||||
|
|
|
||||||
|
|
@ -14,50 +14,50 @@ export default function ApiKeyModal({ onSave }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#050505] flex items-center justify-center px-4">
|
<div className="min-h-screen bg-[#030303] flex items-center justify-center px-4 font-inter">
|
||||||
<div className="w-full max-w-md bg-[#0a0a0a] border border-white/10 rounded-3xl p-8">
|
<div className="w-full max-w-sm bg-[#0a0a0a]/40 backdrop-blur-xl border border-white/10 rounded-xl p-10 shadow-2xl">
|
||||||
<div className="flex flex-col items-center text-center mb-8">
|
<div className="flex flex-col items-center text-center mb-10">
|
||||||
<div className="w-16 h-16 bg-[#d9ff00]/10 rounded-2xl flex items-center justify-center border border-[#d9ff00]/20 mb-6">
|
<div className="w-14 h-14 bg-[#d9ff00]/5 rounded-2xl flex items-center justify-center border border-[#d9ff00]/10 mb-6 group hover:border-[#d9ff00]/30 transition-colors">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="1.5" className="group-hover:scale-110 transition-transform">
|
||||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L12 17.25l-4.5-4.5L15.5 7.5z"/>
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L12 17.25l-4.5-4.5L15.5 7.5z" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-black text-white uppercase tracking-wider mb-2">
|
<h1 className="text-xl font-bold text-white tracking-tight mb-2">
|
||||||
Open Higgsfield AI
|
Open Higgsfield AI
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-white/40 text-sm">
|
<p className="text-white/40 text-[13px] leading-relaxed px-4">
|
||||||
Enter your <a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">Muapi.ai</a> API key to start generating
|
Enter your <a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:text-[#e5ff33] transition-colors">Muapi.ai</a> API key to start creating
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-bold text-white/40 uppercase tracking-widest mb-2">
|
<label className="block text-xs font-bold text-white/30 ml-1">
|
||||||
Muapi API Key
|
API Access Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => { setKey(e.target.value); setError(''); }}
|
onChange={(e) => { setKey(e.target.value); setError(''); }}
|
||||||
placeholder="Enter your API key..."
|
placeholder="Paste your key here..."
|
||||||
className="w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-white placeholder:text-white/20 focus:outline-none focus:border-[#d9ff00]/40 transition-colors"
|
className="w-full bg-white/5 border border-white/[0.03] rounded-md px-5 py-3 text-sm text-white placeholder:text-white/10 focus:outline-none focus:ring-1 focus:ring-[#d9ff00]/30 focus:bg-white/[0.07] transition-all"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
/>
|
/>
|
||||||
{error && <p className="mt-1 text-red-400 text-xs">{error}</p>}
|
{error && <p className="mt-2 text-red-500/80 text-[11px] font-medium ml-1">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#d9ff00] text-black font-black py-3 rounded-xl hover:opacity-90 transition-opacity"
|
className="w-full bg-[#d9ff00] text-black font-medium py-2.5 rounded-md hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all shadow-lg shadow-[#d9ff00]/5"
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
Launch Studio
|
Get Started
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-white/30">
|
<p className="text-center text-[12px] text-white/20 pt-2">
|
||||||
Don't have a key?{' '}
|
Need a key?{' '}
|
||||||
<a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-[#d9ff00] hover:underline">
|
<a href="https://muapi.ai" target="_blank" rel="noreferrer" className="text-white/40 hover:text-[#d9ff00] transition-colors font-medium">
|
||||||
Get one free at Muapi.ai →
|
Get one free →
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio } from 'studio';
|
import { ImageStudio, VideoStudio, LipSyncStudio, CinemaStudio, getUserBalance } from 'studio';
|
||||||
import ApiKeyModal from './ApiKeyModal';
|
import ApiKeyModal from './ApiKeyModal';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
|
@ -16,25 +16,47 @@ const STORAGE_KEY = 'muapi_key';
|
||||||
export default function StandaloneShell() {
|
export default function StandaloneShell() {
|
||||||
const [apiKey, setApiKey] = useState(null);
|
const [apiKey, setApiKey] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('image');
|
const [activeTab, setActiveTab] = useState('image');
|
||||||
|
const [balance, setBalance] = useState(null);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
|
||||||
|
const fetchBalance = useCallback(async (key) => {
|
||||||
|
try {
|
||||||
|
const data = await getUserBalance(key);
|
||||||
|
setBalance(data.balance);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Balance fetch failed:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasMounted(true);
|
setHasMounted(true);
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) setApiKey(stored);
|
if (stored) {
|
||||||
}, []);
|
setApiKey(stored);
|
||||||
|
fetchBalance(stored);
|
||||||
|
}
|
||||||
|
}, [fetchBalance]);
|
||||||
|
|
||||||
const handleKeySave = useCallback((key) => {
|
const handleKeySave = useCallback((key) => {
|
||||||
localStorage.setItem(STORAGE_KEY, key);
|
localStorage.setItem(STORAGE_KEY, key);
|
||||||
setApiKey(key);
|
setApiKey(key);
|
||||||
}, []);
|
fetchBalance(key);
|
||||||
|
}, [fetchBalance]);
|
||||||
|
|
||||||
const handleKeyChange = useCallback(() => {
|
const handleKeyChange = useCallback(() => {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
setApiKey(null);
|
setApiKey(null);
|
||||||
|
setBalance(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Poll for balance every 30 seconds if key is present
|
||||||
|
useEffect(() => {
|
||||||
|
if (!apiKey) return;
|
||||||
|
const interval = setInterval(() => fetchBalance(apiKey), 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [apiKey, fetchBalance]);
|
||||||
|
|
||||||
if (!hasMounted) return (
|
if (!hasMounted) return (
|
||||||
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
||||||
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
<div className="animate-spin text-[#d9ff00] text-3xl">◌</div>
|
||||||
|
|
@ -46,39 +68,55 @@ export default function StandaloneShell() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-[#050505] flex flex-col overflow-hidden">
|
<div className="h-screen bg-[#030303] flex flex-col overflow-hidden text-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex-shrink-0 flex items-center justify-between px-4 pt-4 pb-0 border-b border-white/5">
|
<header className="flex-shrink-0 h-14 border-b border-white/[0.03] flex items-center justify-between px-6 bg-black/20 backdrop-blur-md z-40">
|
||||||
<div className="flex items-center gap-3">
|
{/* Left: Logo */}
|
||||||
<span className="text-white font-black text-lg tracking-wider uppercase">
|
<div className="flex items-center gap-2">
|
||||||
Open Higgsfield AI
|
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center">
|
||||||
</span>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold tracking-tight hidden sm:block">OpenHiggsfield</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Center: Navigation */}
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="absolute left-1/2 -translate-x-1/2 flex items-center gap-6">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
|
className={`relative py-4 text-[13px] font-medium transition-all whitespace-nowrap px-1 ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-[#d9ff00] text-black'
|
? 'text-[#d9ff00]'
|
||||||
: 'text-white/50 hover:text-white'
|
: 'text-white/50 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-[#d9ff00] rounded-full" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Right: Actions */}
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3 bg-white/5 px-3 py-1.5 rounded-full border border-white/5 transition-colors">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs font-bold text-white/90">
|
||||||
|
${balance !== null ? `${balance}` : '---'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
onClick={() => setShowSettings(true)}
|
onClick={() => setShowSettings(true)}
|
||||||
className="text-white/40 hover:text-white text-sm transition-colors"
|
className="w-8 h-8 rounded-full bg-gradient-to-tr from-[#d9ff00] to-yellow-200 border border-white/20 cursor-pointer hover:scale-105 transition-transform"
|
||||||
>
|
/>
|
||||||
⚙ Settings
|
</div>
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Studio Content */}
|
{/* Studio Content */}
|
||||||
|
|
@ -91,22 +129,34 @@ export default function StandaloneShell() {
|
||||||
|
|
||||||
{/* Settings Modal */}
|
{/* Settings Modal */}
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up">
|
||||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-8 w-full max-w-md">
|
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl p-8 w-full max-w-sm shadow-2xl">
|
||||||
<h2 className="text-white font-bold text-xl mb-6">Settings</h2>
|
<h2 className="text-white font-bold text-lg mb-2">Settings</h2>
|
||||||
<p className="text-white/50 text-sm mb-4">
|
<p className="text-white/40 text-[13px] mb-8">
|
||||||
Current API key: <span className="text-white/80 font-mono">{apiKey.slice(0, 8)}••••••••</span>
|
Manage your AI studio preferences and authentication.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-8">
|
||||||
|
<div className="bg-white/5 border border-white/[0.03] rounded-md p-4">
|
||||||
|
<label className="block text-xs font-bold text-white/30 mb-2">
|
||||||
|
Active API Key
|
||||||
|
</label>
|
||||||
|
<div className="text-[13px] font-mono text-white/80">
|
||||||
|
{apiKey.slice(0, 8)}••••••••••••••••
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleKeyChange}
|
onClick={handleKeyChange}
|
||||||
className="flex-1 py-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm transition-colors"
|
className="flex-1 h-10 rounded-md bg-red-500/10 text-red-400 hover:bg-red-500/20 text-xs font-semibold transition-all"
|
||||||
>
|
>
|
||||||
Change API Key
|
Change Key
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(false)}
|
onClick={() => setShowSettings(false)}
|
||||||
className="flex-1 py-2 rounded-lg bg-white/5 text-white hover:bg-white/10 text-sm transition-colors"
|
className="flex-1 h-10 rounded-md bg-white/5 text-white/80 hover:bg-white/10 text-xs font-semibold transition-all border border-white/5"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { generateImage } from '../muapi.js';
|
import { generateImage } from "../muapi.js";
|
||||||
|
|
||||||
// ─── Constants (inlined from promptUtils) ───────────────────────────────────
|
// ─── Constants (inlined from promptUtils) ───────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ const CAMERA_MAP = {
|
||||||
"Grand Format 70mm Film": "grand format 70mm film camera",
|
"Grand Format 70mm Film": "grand format 70mm film camera",
|
||||||
"Studio Digital S35": "Super 35 studio digital camera",
|
"Studio Digital S35": "Super 35 studio digital camera",
|
||||||
"Classic 16mm Film": "classic 16mm film camera",
|
"Classic 16mm Film": "classic 16mm film camera",
|
||||||
"Premium Large Format Digital": "premium large-format digital cinema camera"
|
"Premium Large Format Digital": "premium large-format digital cinema camera",
|
||||||
};
|
};
|
||||||
|
|
||||||
const LENS_MAP = {
|
const LENS_MAP = {
|
||||||
|
|
@ -25,7 +25,7 @@ const LENS_MAP = {
|
||||||
"Swirl Bokeh Portrait": "swirl bokeh portrait lens",
|
"Swirl Bokeh Portrait": "swirl bokeh portrait lens",
|
||||||
"Vintage Prime": "vintage prime lens",
|
"Vintage Prime": "vintage prime lens",
|
||||||
"Halation Diffusion": "halation diffusion filter",
|
"Halation Diffusion": "halation diffusion filter",
|
||||||
"Clinical Sharp Prime": "ultra-sharp clinical prime lens"
|
"Clinical Sharp Prime": "ultra-sharp clinical prime lens",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FOCAL_PERSPECTIVE = {
|
const FOCAL_PERSPECTIVE = {
|
||||||
|
|
@ -34,13 +34,13 @@ const FOCAL_PERSPECTIVE = {
|
||||||
24: "wide-angle dynamic perspective",
|
24: "wide-angle dynamic perspective",
|
||||||
35: "natural cinematic perspective",
|
35: "natural cinematic perspective",
|
||||||
50: "standard portrait perspective",
|
50: "standard portrait perspective",
|
||||||
85: "classic portrait perspective"
|
85: "classic portrait perspective",
|
||||||
};
|
};
|
||||||
|
|
||||||
const APERTURE_EFFECT = {
|
const APERTURE_EFFECT = {
|
||||||
"f/1.4": "shallow depth of field, creamy bokeh",
|
"f/1.4": "shallow depth of field, creamy bokeh",
|
||||||
"f/4": "balanced depth of field",
|
"f/4": "balanced depth of field",
|
||||||
"f/11": "deep focus clarity, sharp foreground to background"
|
"f/11": "deep focus clarity, sharp foreground to background",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ASSET_URLS = {
|
const ASSET_URLS = {
|
||||||
|
|
@ -49,7 +49,8 @@ const ASSET_URLS = {
|
||||||
"Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
|
"Grand Format 70mm Film": "/assets/cinema/grand_format_70mm_film.webp",
|
||||||
"Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
|
"Studio Digital S35": "/assets/cinema/studio_digital_s35.webp",
|
||||||
"Classic 16mm Film": "/assets/cinema/classic_16mm_film.webp",
|
"Classic 16mm Film": "/assets/cinema/classic_16mm_film.webp",
|
||||||
"Premium Large Format Digital": "/assets/cinema/premium_large_format_digital.webp",
|
"Premium Large Format Digital":
|
||||||
|
"/assets/cinema/premium_large_format_digital.webp",
|
||||||
"Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
|
"Creative Tilt Lens": "/assets/cinema/creative_tilt_lens.webp",
|
||||||
"Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
|
"Compact Anamorphic": "/assets/cinema/compact_anamorphic.webp",
|
||||||
"Extreme Macro": "/assets/cinema/extreme_macro.webp",
|
"Extreme Macro": "/assets/cinema/extreme_macro.webp",
|
||||||
|
|
@ -63,34 +64,44 @@ const ASSET_URLS = {
|
||||||
"Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
|
"Clinical Sharp Prime": "/assets/cinema/clinical_sharp_prime.webp",
|
||||||
"f/1.4": "/assets/cinema/f_1_4.webp",
|
"f/1.4": "/assets/cinema/f_1_4.webp",
|
||||||
"f/4": "/assets/cinema/f_4.webp",
|
"f/4": "/assets/cinema/f_4.webp",
|
||||||
"f/11": "/assets/cinema/f_11.webp"
|
"f/11": "/assets/cinema/f_11.webp",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ASPECT_RATIOS = ['16:9', '21:9', '9:16', '1:1', '4:5'];
|
const ASPECT_RATIOS = ["16:9", "21:9", "9:16", "1:1", "4:5"];
|
||||||
const RESOLUTIONS = ['1K', '2K', '4K'];
|
const RESOLUTIONS = ["1K", "2K", "4K"];
|
||||||
const CAMERAS = Object.keys(CAMERA_MAP);
|
const CAMERAS = Object.keys(CAMERA_MAP);
|
||||||
const LENSES = Object.keys(LENS_MAP);
|
const LENSES = Object.keys(LENS_MAP);
|
||||||
const FOCAL_LENGTHS = Object.keys(FOCAL_PERSPECTIVE).map(k => parseInt(k));
|
const FOCAL_LENGTHS = Object.keys(FOCAL_PERSPECTIVE).map((k) => parseInt(k));
|
||||||
const APERTURES = Object.keys(APERTURE_EFFECT);
|
const APERTURES = Object.keys(APERTURE_EFFECT);
|
||||||
|
|
||||||
function buildNanoBananaPrompt(basePrompt, camera, lens, focalLength, aperture) {
|
function buildNanoBananaPrompt(
|
||||||
|
basePrompt,
|
||||||
|
camera,
|
||||||
|
lens,
|
||||||
|
focalLength,
|
||||||
|
aperture,
|
||||||
|
) {
|
||||||
const cameraDesc = CAMERA_MAP[camera] || camera;
|
const cameraDesc = CAMERA_MAP[camera] || camera;
|
||||||
const lensDesc = LENS_MAP[lens] || lens;
|
const lensDesc = LENS_MAP[lens] || lens;
|
||||||
const perspective = FOCAL_PERSPECTIVE[focalLength] || "";
|
const perspective = FOCAL_PERSPECTIVE[focalLength] || "";
|
||||||
const depthEffect = APERTURE_EFFECT[aperture] || "";
|
const depthEffect = APERTURE_EFFECT[aperture] || "";
|
||||||
const qualityTags = ["professional photography", "ultra-detailed", "8K resolution"];
|
const qualityTags = [
|
||||||
|
"professional photography",
|
||||||
|
"ultra-detailed",
|
||||||
|
"8K resolution",
|
||||||
|
];
|
||||||
const parts = [
|
const parts = [
|
||||||
basePrompt,
|
basePrompt,
|
||||||
`shot on a ${cameraDesc}`,
|
`shot on a ${cameraDesc}`,
|
||||||
`using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ''}`,
|
`using a ${lensDesc} at ${focalLength}mm ${perspective ? `(${perspective})` : ""}`,
|
||||||
`aperture ${aperture}`,
|
`aperture ${aperture}`,
|
||||||
depthEffect,
|
depthEffect,
|
||||||
"cinematic lighting",
|
"cinematic lighting",
|
||||||
"natural color science",
|
"natural color science",
|
||||||
"high dynamic range",
|
"high dynamic range",
|
||||||
qualityTags.join(", ")
|
qualityTags.join(", "),
|
||||||
];
|
];
|
||||||
return parts.filter(p => p && p.trim() !== "").join(", ");
|
return parts.filter((p) => p && p.trim() !== "").join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dropdown ────────────────────────────────────────────────────────────────
|
// ─── Dropdown ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -104,7 +115,7 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
|
||||||
const rect = triggerRef.current.getBoundingClientRect();
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
setPosition({
|
setPosition({
|
||||||
bottom: window.innerHeight - rect.top + 8,
|
bottom: window.innerHeight - rect.top + 8,
|
||||||
left: rect.left
|
left: rect.left,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,10 +129,13 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => document.addEventListener('click', handler), 0);
|
const timer = setTimeout(
|
||||||
|
() => document.addEventListener("click", handler),
|
||||||
|
0,
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
document.removeEventListener('click', handler);
|
document.removeEventListener("click", handler);
|
||||||
};
|
};
|
||||||
}, [triggerRef, onClose]);
|
}, [triggerRef, onClose]);
|
||||||
|
|
||||||
|
|
@ -131,10 +145,10 @@ function Dropdown({ items, selected, onSelect, triggerRef, onClose }) {
|
||||||
className="custom-dropdown fixed bg-[#1a1a1a] border border-white/10 rounded-xl py-1 shadow-2xl z-50 flex flex-col min-w-[100px] animate-fade-in"
|
className="custom-dropdown fixed bg-[#1a1a1a] border border-white/10 rounded-xl py-1 shadow-2xl z-50 flex flex-col min-w-[100px] animate-fade-in"
|
||||||
style={{ bottom: position.bottom, left: position.left }}
|
style={{ bottom: position.bottom, left: position.left }}
|
||||||
>
|
>
|
||||||
{items.map(item => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
className={`px-3 py-2 text-xs font-bold text-left hover:bg-white/10 transition-colors ${item === selected ? 'text-primary' : 'text-white'}`}
|
className={`px-3 py-2 text-xs font-bold text-left hover:bg-white/10 transition-colors ${item === selected ? "text-primary" : "text-white"}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(item);
|
onSelect(item);
|
||||||
|
|
@ -163,9 +177,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const target = Array.from(list.children).find(
|
const target = Array.from(list.children).find(
|
||||||
c => c.dataset.value == String(value)
|
(c) => c.dataset.value == String(value),
|
||||||
);
|
);
|
||||||
if (target) target.scrollIntoView({ block: 'center' });
|
if (target) target.scrollIntoView({ block: "center" });
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -177,8 +191,8 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
let closest = null;
|
let closest = null;
|
||||||
let minDist = Infinity;
|
let minDist = Infinity;
|
||||||
|
|
||||||
const children = Array.from(list.children).filter(c => c.dataset.value);
|
const children = Array.from(list.children).filter((c) => c.dataset.value);
|
||||||
children.forEach(child => {
|
children.forEach((child) => {
|
||||||
const childCenter = child.offsetTop + child.offsetHeight / 2;
|
const childCenter = child.offsetTop + child.offsetHeight / 2;
|
||||||
const dist = Math.abs(centerY - childCenter);
|
const dist = Math.abs(centerY - childCenter);
|
||||||
if (dist < minDist) {
|
if (dist < minDist) {
|
||||||
|
|
@ -187,35 +201,44 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
children.forEach(child => {
|
children.forEach((child) => {
|
||||||
const imgBox = child.querySelector('[data-imgbox]');
|
const imgBox = child.querySelector("[data-imgbox]");
|
||||||
const label = child.querySelector('[data-label]');
|
const label = child.querySelector("[data-label]");
|
||||||
const focalSpan = imgBox?.querySelector('[data-focal-text]');
|
const focalSpan = imgBox?.querySelector("[data-focal-text]");
|
||||||
const isClosest = child === closest;
|
const isClosest = child === closest;
|
||||||
|
|
||||||
if (isClosest) {
|
if (isClosest) {
|
||||||
child.classList.remove('opacity-30', 'scale-75', 'blur-[1px]');
|
child.classList.remove("opacity-30", "scale-75", "blur-[1px]");
|
||||||
child.classList.add('opacity-100', 'scale-100', 'blur-0', 'z-30');
|
child.classList.add("opacity-100", "scale-100", "blur-0", "z-30");
|
||||||
if (imgBox) {
|
if (imgBox) {
|
||||||
imgBox.classList.add('border-primary/50', 'shadow-glow-sm', 'scale-110');
|
imgBox.classList.add(
|
||||||
imgBox.classList.remove('border-white/10', 'bg-white/5');
|
"border-primary/50",
|
||||||
|
"shadow-glow-sm",
|
||||||
|
"scale-110",
|
||||||
|
);
|
||||||
|
imgBox.classList.remove("border-white/10", "bg-white/5");
|
||||||
}
|
}
|
||||||
if (focalSpan) focalSpan.classList.add('text-primary');
|
if (focalSpan) focalSpan.classList.add("text-primary");
|
||||||
if (label) label.classList.add('text-primary', 'text-shadow-sm');
|
if (label) label.classList.add("text-primary", "text-shadow-sm");
|
||||||
} else {
|
} else {
|
||||||
child.classList.add('opacity-30', 'scale-75', 'blur-[1px]');
|
child.classList.add("opacity-30", "scale-75", "blur-[1px]");
|
||||||
child.classList.remove('opacity-100', 'scale-100', 'blur-0', 'z-30');
|
child.classList.remove("opacity-100", "scale-100", "blur-0", "z-30");
|
||||||
if (imgBox) {
|
if (imgBox) {
|
||||||
imgBox.classList.remove('border-primary/50', 'shadow-glow-sm', 'scale-110');
|
imgBox.classList.remove(
|
||||||
imgBox.classList.add('border-white/10', 'bg-white/5');
|
"border-primary/50",
|
||||||
|
"shadow-glow-sm",
|
||||||
|
"scale-110",
|
||||||
|
);
|
||||||
|
imgBox.classList.add("border-white/10", "bg-white/5");
|
||||||
}
|
}
|
||||||
if (focalSpan) focalSpan.classList.remove('text-primary');
|
if (focalSpan) focalSpan.classList.remove("text-primary");
|
||||||
if (label) label.classList.remove('text-primary', 'text-shadow-sm');
|
if (label) label.classList.remove("text-primary", "text-shadow-sm");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (closest) {
|
if (closest) {
|
||||||
const newVal = columnKey === 'focal'
|
const newVal =
|
||||||
|
columnKey === "focal"
|
||||||
? parseInt(closest.dataset.value)
|
? parseInt(closest.dataset.value)
|
||||||
: closest.dataset.value;
|
: closest.dataset.value;
|
||||||
if (String(newVal) !== String(value)) {
|
if (String(newVal) !== String(value)) {
|
||||||
|
|
@ -228,10 +251,10 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list.addEventListener('scroll', handleScroll);
|
list.addEventListener("scroll", handleScroll);
|
||||||
const timer = setTimeout(handleScroll, 150);
|
const timer = setTimeout(handleScroll, 150);
|
||||||
return () => {
|
return () => {
|
||||||
list.removeEventListener('scroll', handleScroll);
|
list.removeEventListener("scroll", handleScroll);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [handleScroll]);
|
}, [handleScroll]);
|
||||||
|
|
@ -240,8 +263,8 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
const onMouseDown = (e) => {
|
const onMouseDown = (e) => {
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
isSnapEnabled.current = false;
|
isSnapEnabled.current = false;
|
||||||
listRef.current.classList.add('cursor-grabbing');
|
listRef.current.classList.add("cursor-grabbing");
|
||||||
listRef.current.classList.remove('snap-y');
|
listRef.current.classList.remove("snap-y");
|
||||||
startY.current = e.pageY - listRef.current.offsetTop;
|
startY.current = e.pageY - listRef.current.offsetTop;
|
||||||
scrollTopStart.current = listRef.current.scrollTop;
|
scrollTopStart.current = listRef.current.scrollTop;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -249,14 +272,14 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
listRef.current.classList.remove('cursor-grabbing');
|
listRef.current.classList.remove("cursor-grabbing");
|
||||||
listRef.current.classList.add('snap-y');
|
listRef.current.classList.add("snap-y");
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseUp = () => {
|
const onMouseUp = () => {
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
listRef.current.classList.remove('cursor-grabbing');
|
listRef.current.classList.remove("cursor-grabbing");
|
||||||
listRef.current.classList.add('snap-y');
|
listRef.current.classList.add("snap-y");
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseMove = (e) => {
|
const onMouseMove = (e) => {
|
||||||
|
|
@ -271,9 +294,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const target = Array.from(list.children).find(
|
const target = Array.from(list.children).find(
|
||||||
c => c.dataset.value == String(item)
|
(c) => c.dataset.value == String(item),
|
||||||
);
|
);
|
||||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (target) target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -281,13 +304,13 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
<div className="mb-3 text-[9px] font-black text-white/40 uppercase tracking-[0.2em] text-center">
|
<div className="mb-3 text-[9px] font-black text-white/40 uppercase tracking-[0.2em] text-center">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative overflow-hidden w-full h-[40vh] md:h-[320px] bg-[#1a1a1a]/80 rounded-[2rem] border border-white/5 shadow-2xl backdrop-blur-xl transition-transform duration-300 hover:scale-[1.02] hover:border-white/10">
|
<div className="relative overflow-hidden w-full h-[40vh] md:h-[320px] bg-[#050505]/60 rounded-2xl border border-white/[0.05] shadow-2xl backdrop-blur-2xl transition-transform duration-500 hover:scale-[1.01] hover:border-white/[0.1]">
|
||||||
{/* Top mask */}
|
{/* Top mask */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-24 bg-gradient-to-b from-[#1a1a1a] via-[#1a1a1a]/80 to-transparent z-20 pointer-events-none rounded-t-[2rem]" />
|
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
|
||||||
{/* Bottom mask */}
|
{/* Bottom mask */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-[#1a1a1a] via-[#1a1a1a]/80 to-transparent z-20 pointer-events-none rounded-b-[2rem]" />
|
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-[#0a0a0a] via-[#0a0a0a]/40 to-transparent z-20 pointer-events-none" />
|
||||||
{/* Center glow */}
|
{/* Center selection indicator */}
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4/5 h-[80px] bg-primary/5 blur-xl rounded-full pointer-events-none z-0" />
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[85%] h-[80px] bg-primary/[0.03] border border-primary/[0.1] rounded-2xl pointer-events-none z-0" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
|
|
@ -298,9 +321,9 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
>
|
>
|
||||||
{/* Top spacer */}
|
{/* Top spacer */}
|
||||||
<div style={{ height: 'calc(50% - 50px)' }} />
|
<div style={{ height: "calc(50% - 50px)" }} />
|
||||||
|
|
||||||
{items.map(item => {
|
{items.map((item) => {
|
||||||
const imageUrl = ASSET_URLS[item];
|
const imageUrl = ASSET_URLS[item];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -319,8 +342,11 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
alt={String(item)}
|
alt={String(item)}
|
||||||
className="w-full h-full object-cover opacity-80"
|
className="w-full h-full object-cover opacity-80"
|
||||||
/>
|
/>
|
||||||
) : columnKey === 'focal' ? (
|
) : columnKey === "focal" ? (
|
||||||
<span data-focal-text="true" className="text-lg font-bold text-white/50">
|
<span
|
||||||
|
data-focal-text="true"
|
||||||
|
className="text-lg font-bold text-white/50"
|
||||||
|
>
|
||||||
{item}
|
{item}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -338,16 +364,19 @@ function ScrollColumn({ title, items, columnKey, value, onChange }) {
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Bottom spacer */}
|
{/* Bottom spacer */}
|
||||||
<div style={{ height: 'calc(50% - 50px)' }} />
|
<div style={{ height: "calc(50% - 50px)" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Camera Controls Overlay ─────────────────────────────────────────────────
|
function CameraControlsOverlay({
|
||||||
|
isOpen,
|
||||||
function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange }) {
|
onClose,
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
}) {
|
||||||
const backdropRef = useRef(null);
|
const backdropRef = useRef(null);
|
||||||
|
|
||||||
const handleBackdropClick = (e) => {
|
const handleBackdropClick = (e) => {
|
||||||
|
|
@ -355,28 +384,40 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSetting = (key) => (val) => {
|
const updateSetting = (key) => (val) => {
|
||||||
onSettingsChange(prev => ({ ...prev, [key]: val }));
|
onSettingsChange((prev) => ({ ...prev, [key]: val }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={backdropRef}
|
ref={backdropRef}
|
||||||
className={`fixed inset-0 bg-black/80 backdrop-blur-md z-40 flex items-center justify-center transition-opacity duration-300 ${isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
|
className={`fixed inset-0 bg-[#0a0a0a]/80 backdrop-blur-2xl z-[100] flex items-center justify-center transition-all duration-500 ${isOpen ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-4xl bg-[#141414] border border-white/10 rounded-3xl p-4 md:p-8 shadow-2xl transform transition-transform duration-300 flex flex-col max-h-[90vh] ${isOpen ? 'scale-100' : 'scale-95'}`}
|
className={`w-full max-w-5xl bg-[#0a0a0a]/60 border border-white/10 rounded-2xl p-6 md:p-10 shadow-3xl transform transition-all duration-500 flex flex-col max-h-[90vh] ${isOpen ? "scale-100 translate-y-0" : "scale-95 translate-y-10"}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-10">
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-1">
|
||||||
<button className="px-4 py-2 bg-white text-black text-xs font-bold rounded-full">All</button>
|
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||||
|
Camera Configuration
|
||||||
|
</h2>
|
||||||
|
<p className="text-[11px] font-medium text-white/20 uppercase tracking-[0.2em]">
|
||||||
|
Select hardware & optics
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-white/50 hover:text-white transition-colors"
|
className="w-10 h-10 rounded-full bg-white/[0.03] border border-white/[0.05] flex items-center justify-center text-white/40 hover:text-white hover:bg-white/[0.06] transition-all"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
>
|
>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -389,28 +430,28 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
|
||||||
items={CAMERAS}
|
items={CAMERAS}
|
||||||
columnKey="camera"
|
columnKey="camera"
|
||||||
value={settings.camera}
|
value={settings.camera}
|
||||||
onChange={updateSetting('camera')}
|
onChange={updateSetting("camera")}
|
||||||
/>
|
/>
|
||||||
<ScrollColumn
|
<ScrollColumn
|
||||||
title="Lens"
|
title="Lens"
|
||||||
items={LENSES}
|
items={LENSES}
|
||||||
columnKey="lens"
|
columnKey="lens"
|
||||||
value={settings.lens}
|
value={settings.lens}
|
||||||
onChange={updateSetting('lens')}
|
onChange={updateSetting("lens")}
|
||||||
/>
|
/>
|
||||||
<ScrollColumn
|
<ScrollColumn
|
||||||
title="Focal Length"
|
title="Focal Length"
|
||||||
items={FOCAL_LENGTHS}
|
items={FOCAL_LENGTHS}
|
||||||
columnKey="focal"
|
columnKey="focal"
|
||||||
value={settings.focal}
|
value={settings.focal}
|
||||||
onChange={updateSetting('focal')}
|
onChange={updateSetting("focal")}
|
||||||
/>
|
/>
|
||||||
<ScrollColumn
|
<ScrollColumn
|
||||||
title="Aperture"
|
title="Aperture"
|
||||||
items={APERTURES}
|
items={APERTURES}
|
||||||
columnKey="aperture"
|
columnKey="aperture"
|
||||||
value={settings.aperture}
|
value={settings.aperture}
|
||||||
onChange={updateSetting('aperture')}
|
onChange={updateSetting("aperture")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -420,23 +461,30 @@ function CameraControlsOverlay({ isOpen, onClose, settings, onSettingsChange })
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CinemaStudio({ apiKey, onGenerationComplete, historyItems }) {
|
export default function CinemaStudio({
|
||||||
|
apiKey,
|
||||||
|
onGenerationComplete,
|
||||||
|
historyItems,
|
||||||
|
}) {
|
||||||
|
const PERSIST_KEY = "hg_cinema_studio_persistent";
|
||||||
|
|
||||||
// ── Settings state ──
|
// ── Settings state ──
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
prompt: '',
|
prompt: "",
|
||||||
aspect_ratio: '16:9',
|
aspect_ratio: "16:9",
|
||||||
camera: CAMERAS[0],
|
camera: CAMERAS[0],
|
||||||
lens: LENSES[0],
|
lens: LENSES[0],
|
||||||
focal: 35,
|
focal: 35,
|
||||||
aperture: 'f/1.4'
|
aperture: "f/1.4",
|
||||||
});
|
});
|
||||||
const [resolution, setResolution] = useState('2K');
|
const [resolution, setResolution] = useState("2K");
|
||||||
|
|
||||||
// ── UI state ──
|
// ── UI state ──
|
||||||
const [isOverlayOpen, setIsOverlayOpen] = useState(false);
|
const [isOverlayOpen, setIsOverlayOpen] = useState(false);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [canvasUrl, setCanvasUrl] = useState(null); // null = prompt view
|
const [canvasUrl, setCanvasUrl] = useState(null); // null = prompt view
|
||||||
const [activeHistoryIndex, setActiveHistoryIndex] = useState(null);
|
const [fullscreenUrl, setFullscreenUrl] = useState(null);
|
||||||
|
const [activeHistoryIndex, setactiveHistoryIndex] = useState(null);
|
||||||
|
|
||||||
// ── Internal history state (used when historyItems prop is not provided) ──
|
// ── Internal history state (used when historyItems prop is not provided) ──
|
||||||
const [internalHistory, setInternalHistory] = useState([]);
|
const [internalHistory, setInternalHistory] = useState([]);
|
||||||
|
|
@ -450,18 +498,55 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const resultImgRef = useRef(null);
|
const resultImgRef = useRef(null);
|
||||||
|
|
||||||
|
// ── Persistence: Load ────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(PERSIST_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const data = JSON.parse(stored);
|
||||||
|
if (data.settings) setSettings(data.settings);
|
||||||
|
if (data.resolution) setResolution(data.resolution);
|
||||||
|
if (data.internalHistory) setInternalHistory(data.internalHistory);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load CinemaStudio persistence:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Persistence: Save ────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const state = {
|
||||||
|
settings,
|
||||||
|
resolution,
|
||||||
|
internalHistory,
|
||||||
|
};
|
||||||
|
localStorage.setItem(PERSIST_KEY, JSON.stringify(state));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to save CinemaStudio persistence:", err);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [settings, resolution, internalHistory]);
|
||||||
|
|
||||||
// Derive effective history (prop wins over internal)
|
// Derive effective history (prop wins over internal)
|
||||||
const history = historyItems != null ? historyItems : internalHistory;
|
const history = historyItems != null ? historyItems : internalHistory;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCanvasUrl(history[0]?.url || null);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [historyItems]);
|
||||||
|
|
||||||
const formatSummaryValue = () =>
|
const formatSummaryValue = () =>
|
||||||
`${settings.lens}, ${settings.focal}mm, ${settings.aperture}`;
|
`${settings.lens}, ${settings.focal}mm, ${settings.aperture}`;
|
||||||
|
|
||||||
// ── Textarea auto-height ──
|
// ── Textarea auto-height ──
|
||||||
const handleTextareaInput = (e) => {
|
const handleTextareaInput = (e) => {
|
||||||
const el = e.target;
|
const el = e.target;
|
||||||
el.style.height = 'auto';
|
el.style.height = "auto";
|
||||||
el.style.height = el.scrollHeight + 'px';
|
el.style.height = el.scrollHeight + "px";
|
||||||
setSettings(prev => ({ ...prev, prompt: el.value }));
|
setSettings((prev) => ({ ...prev, prompt: el.value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Generate ──
|
// ── Generate ──
|
||||||
|
|
@ -476,16 +561,16 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
|
||||||
settings.camera,
|
settings.camera,
|
||||||
settings.lens,
|
settings.lens,
|
||||||
settings.focal,
|
settings.focal,
|
||||||
settings.aperture
|
settings.aperture,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await generateImage(apiKey, {
|
const res = await generateImage(apiKey, {
|
||||||
model: 'nano-banana-pro',
|
model: "nano-banana-pro",
|
||||||
prompt: finalPrompt,
|
prompt: finalPrompt,
|
||||||
aspect_ratio: settings.aspect_ratio,
|
aspect_ratio: settings.aspect_ratio,
|
||||||
resolution: resolution.toLowerCase(),
|
resolution: resolution.toLowerCase(),
|
||||||
negative_prompt: 'blurry, low quality, distortion, bad composition'
|
negative_prompt: "blurry, low quality, distortion, bad composition",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.url) {
|
if (res && res.url) {
|
||||||
|
|
@ -499,36 +584,42 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
|
||||||
focal: settings.focal,
|
focal: settings.focal,
|
||||||
aperture: settings.aperture,
|
aperture: settings.aperture,
|
||||||
aspect_ratio: settings.aspect_ratio,
|
aspect_ratio: settings.aspect_ratio,
|
||||||
resolution
|
resolution,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only update internal history if not using prop-driven history
|
// Only update internal history if not using prop-driven history
|
||||||
if (historyItems == null) {
|
if (historyItems == null) {
|
||||||
setInternalHistory(prev => [entry, ...prev].slice(0, 50));
|
setInternalHistory((prev) => [entry, ...prev].slice(0, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveHistoryIndex(0);
|
|
||||||
setCanvasUrl(res.url);
|
setCanvasUrl(res.url);
|
||||||
|
|
||||||
if (onGenerationComplete) {
|
if (onGenerationComplete) {
|
||||||
onGenerationComplete({
|
onGenerationComplete({
|
||||||
url: res.url,
|
url: res.url,
|
||||||
model: 'nano-banana-pro',
|
model: "nano-banana-pro",
|
||||||
prompt: basePrompt,
|
prompt: basePrompt,
|
||||||
type: 'cinema'
|
type: "cinema",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No data returned');
|
throw new Error("No data returned");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert('Generation Failed: ' + e.message);
|
alert("Generation Failed: " + e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}, [settings, resolution, apiKey, isGenerating, onGenerationComplete, historyItems]);
|
}, [
|
||||||
|
settings,
|
||||||
|
resolution,
|
||||||
|
apiKey,
|
||||||
|
isGenerating,
|
||||||
|
onGenerationComplete,
|
||||||
|
historyItems,
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Regenerate ──
|
// ── Regenerate ──
|
||||||
const handleRegenerate = useCallback(() => {
|
const handleRegenerate = useCallback(() => {
|
||||||
|
|
@ -544,7 +635,7 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
|
||||||
const response = await fetch(canvasUrl);
|
const response = await fetch(canvasUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = blobUrl;
|
a.href = blobUrl;
|
||||||
a.download = `cinema-shot-${Date.now()}.jpg`;
|
a.download = `cinema-shot-${Date.now()}.jpg`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
|
|
@ -552,140 +643,194 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(blobUrl);
|
URL.revokeObjectURL(blobUrl);
|
||||||
} catch {
|
} catch {
|
||||||
window.open(canvasUrl, '_blank');
|
window.open(canvasUrl, "_blank");
|
||||||
}
|
}
|
||||||
}, [canvasUrl]);
|
}, [canvasUrl]);
|
||||||
|
|
||||||
// ── Load history item ──
|
// ── Load history item ──
|
||||||
const loadHistoryItem = (entry, idx) => {
|
const loadHistoryItem = (entry, idx) => {
|
||||||
if (entry.settings) {
|
if (entry.settings) {
|
||||||
setSettings(prev => ({
|
setSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
camera: entry.settings.camera ?? prev.camera,
|
camera: entry.settings.camera ?? prev.camera,
|
||||||
lens: entry.settings.lens ?? prev.lens,
|
lens: entry.settings.lens ?? prev.lens,
|
||||||
focal: entry.settings.focal ?? prev.focal,
|
focal: entry.settings.focal ?? prev.focal,
|
||||||
aperture: entry.settings.aperture ?? prev.aperture,
|
aperture: entry.settings.aperture ?? prev.aperture,
|
||||||
aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio,
|
aspect_ratio: entry.settings.aspect_ratio ?? prev.aspect_ratio,
|
||||||
prompt: entry.settings.prompt ?? prev.prompt
|
prompt: entry.settings.prompt ?? prev.prompt,
|
||||||
}));
|
}));
|
||||||
if (entry.settings.resolution) setResolution(entry.settings.resolution);
|
if (entry.settings.resolution) setResolution(entry.settings.resolution);
|
||||||
|
|
||||||
// Sync textarea height
|
// Sync textarea height
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.value = entry.settings.prompt || '';
|
textareaRef.current.value = entry.settings.prompt || "";
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = "auto";
|
||||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
textareaRef.current.style.height =
|
||||||
|
textareaRef.current.scrollHeight + "px";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setActiveHistoryIndex(idx);
|
|
||||||
setCanvasUrl(entry.url);
|
setCanvasUrl(entry.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetToPrompt = () => {
|
const resetToPrompt = () => {
|
||||||
setCanvasUrl(null);
|
setCanvasUrl(null);
|
||||||
setSettings(prev => ({ ...prev, prompt: '' }));
|
setSettings((prev) => ({ ...prev, prompt: "" }));
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.value = '';
|
textareaRef.current.value = "";
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = "auto";
|
||||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showCanvas = canvasUrl !== null;
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center bg-black relative overflow-hidden">
|
<div className="w-full h-full flex flex-col items-center justify-center bg-black relative overflow-hidden">
|
||||||
|
|
||||||
{/* ── 1. Hero Section (Empty State) ── */}
|
{/* ── 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 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full pt-4 animate-fade-in-up">
|
||||||
|
{history.map((entry, idx) => (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center justify-center text-center px-4 animate-fade-in-up transition-all duration-700 ${showCanvas ? 'opacity-0 pointer-events-none scale-95' : 'opacity-100 scale-100'}`}
|
key={entry.timestamp ?? idx}
|
||||||
|
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-[#d9ff00]/50 transition-all duration-300 flex flex-col cursor-pointer"
|
||||||
|
onClick={() => loadHistoryItem(entry, idx)}
|
||||||
>
|
>
|
||||||
<div className="mb-4 text-xs font-bold text-white/40 tracking-[0.2em] uppercase">
|
<img
|
||||||
Cinema Studio 2.0
|
src={entry.url}
|
||||||
</div>
|
alt={`History item ${idx + 1}`}
|
||||||
<h1 className="text-4xl md:text-6xl font-black text-transparent bg-clip-text bg-gradient-to-b from-white to-white/50 tracking-tight leading-tight mb-2">
|
className="w-full aspect-[4/3] object-cover bg-black/40"
|
||||||
What would you shoot<br />with infinite budget?
|
/>
|
||||||
</h1>
|
|
||||||
|
{/* 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-[#d9ff00] 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={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const response = await fetch(entry.url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = blobUrl;
|
||||||
|
a.download = `cinema-shot-${entry.id || idx}.jpg`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
window.open(entry.url, "_blank");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white hover:bg-[#d9ff00] 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 2. Canvas Area (Result View) ── */}
|
{/* Details */}
|
||||||
<div
|
<div className="p-3 bg-black/80 backdrop-blur-sm border-t border-white/5 flex-1 flex flex-col justify-between gap-2">
|
||||||
className={`absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-30 transition-all duration-1000 bg-black/90 backdrop-blur-3xl ${showCanvas ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-10 scale-95 pointer-events-none'}`}
|
<p className="text-white/70 text-xs line-clamp-3 leading-relaxed">
|
||||||
>
|
{entry.settings?.prompt || "No prompt"}
|
||||||
<div className="relative group max-w-full max-h-[70vh] flex items-center justify-center">
|
</p>
|
||||||
{canvasUrl && (
|
<div className="flex items-center justify-between mt-1 flex-wrap gap-1">
|
||||||
<img
|
<span className="text-[10px] font-bold text-[#d9ff00] px-2 py-0.5 bg-[#d9ff00]/10 rounded border border-[#d9ff00]/20">
|
||||||
ref={resultImgRef}
|
{entry.settings?.camera || "Standard"}
|
||||||
src={canvasUrl}
|
</span>
|
||||||
alt="Generated cinema shot"
|
<div className="flex gap-2">
|
||||||
className="max-h-[60vh] max-w-[90vw] rounded-2xl shadow-2xl border border-white/10 object-contain"
|
<span className="text-[10px] text-white/40">{entry.settings?.lens || "35mm"}</span>
|
||||||
/>
|
{entry.settings?.aspect_ratio && (
|
||||||
|
<span className="text-[10px] text-white/40">{entry.settings.aspect_ratio}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center px-4 animate-fade-in-up transition-all duration-700 min-h-[50vh]">
|
||||||
|
<div className="mb-12 relative group">
|
||||||
|
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
|
||||||
|
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
|
||||||
|
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-primary opacity-80">
|
||||||
|
<path d="M23 7l-7 5 7 5V7z" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-4 right-4 text-[10px] text-primary/40 animate-pulse">REC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
|
||||||
|
<span className="text-white/40 font-medium">START CREATING WITH</span><br />
|
||||||
|
<span className="text-white uppercase tracking-wider">Cinema Studio</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed">
|
||||||
|
What would you shoot with infinite budget?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Canvas Controls */}
|
{/* ── BOTTOM PROMPT BAR ── */}
|
||||||
<div
|
<div className="absolute bottom-4 left-4 right-4 md:left-0 md:right-0 md:mx-auto md:max-w-[95%] lg:max-w-4xl z-30 transition-all duration-700 animate-fade-in-up">
|
||||||
className={`mt-8 flex gap-3 justify-center transition-opacity duration-500 delay-500 ${showCanvas ? 'opacity-100' : 'opacity-0'}`}
|
<div className="bg-[#0a0a0a]/80 backdrop-blur-3xl border border-white/10 rounded-md p-4 flex justify-between shadow-2xl items-end relative gap-2">
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRegenerate()}
|
|
||||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wide transition-all border border-white/5 backdrop-blur-lg text-white hover:border-white/20"
|
|
||||||
>
|
|
||||||
↻ Regenerate
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="bg-[#d9ff00] text-black px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-wide hover:bg-white transition-colors shadow-glow-sm hover:scale-105 active:scale-95"
|
|
||||||
>
|
|
||||||
↓ Download
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={resetToPrompt}
|
|
||||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wide transition-all border border-white/5 backdrop-blur-lg text-white hover:border-white/20"
|
|
||||||
>
|
|
||||||
+ New Shot
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 3. Floating Prompt Bar ── */}
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-8 left-4 right-4 md:left-0 md:right-0 md:mx-auto md:max-w-4xl z-30 transition-all duration-700 ${showCanvas ? 'opacity-0 pointer-events-none translate-y-20' : 'opacity-100 translate-y-0'}`}
|
|
||||||
>
|
|
||||||
<div className="bg-[#1a1a1a] border border-white/10 rounded-[2rem] p-4 flex justify-between shadow-3xl items-end relative">
|
|
||||||
|
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1 px-1">
|
<div className="flex-1 flex flex-col gap-3 min-h-[80px] justify-between py-1">
|
||||||
{/* Input Row */}
|
{/* Input Row */}
|
||||||
<div className="flex items-start gap-3 w-full">
|
<div className="flex items-start gap-4 w-full">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
placeholder="Describe your scene - use @ to add characters & props"
|
placeholder="Describe your cinema scene..."
|
||||||
className="flex-1 bg-transparent border-none text-white text-lg font-medium placeholder:text-white/20 focus:outline-none resize-none h-[28px] leading-relaxed overflow-hidden"
|
className="w-full bg-transparent border-none text-white text-sm placeholder:text-white/10 focus:outline-none resize-none pt-1 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar disabled:opacity-40"
|
||||||
rows={1}
|
rows={1}
|
||||||
onInput={handleTextareaInput}
|
onInput={handleTextareaInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
{/* Settings Toolbar */}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Aspect Ratio Button */}
|
{/* Aspect Ratio Button */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
ref={arBtnRef}
|
ref={arBtnRef}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5"
|
className="flex items-center gap-1.5 px-3 py-1 bg-white/[0.03] hover:bg-white/10 text-xs font-bold text-white/40 hover:text-white transition-colors rounded-md border border-white/[0.03]"
|
||||||
onClick={() => setOpenDropdown(d => d === 'ar' ? null : 'ar')}
|
onClick={() =>
|
||||||
|
setOpenDropdown((d) => (d === "ar" ? null : "ar"))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40">
|
||||||
<rect x="2" y="7" width="20" height="10" rx="2" ry="2" />
|
<rect x="2" y="7" width="20" height="10" rx="2" ry="2" />
|
||||||
</svg>
|
</svg>
|
||||||
{settings.aspect_ratio}
|
{settings.aspect_ratio}
|
||||||
</button>
|
</button>
|
||||||
{openDropdown === 'ar' && (
|
{openDropdown === "ar" && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={ASPECT_RATIOS}
|
items={ASPECT_RATIOS}
|
||||||
selected={settings.aspect_ratio}
|
selected={settings.aspect_ratio}
|
||||||
onSelect={(val) => setSettings(prev => ({ ...prev, aspect_ratio: val }))}
|
onSelect={(val) =>
|
||||||
|
setSettings((prev) => ({ ...prev, aspect_ratio: val }))
|
||||||
|
}
|
||||||
triggerRef={arBtnRef}
|
triggerRef={arBtnRef}
|
||||||
onClose={() => setOpenDropdown(null)}
|
onClose={() => setOpenDropdown(null)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -696,15 +841,17 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
ref={resBtnRef}
|
ref={resBtnRef}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold text-white/50 hover:text-white transition-colors bg-white/5 hover:bg-white/10 rounded-lg border border-white/5"
|
className="flex items-center gap-1.5 px-3 py-1 bg-white/[0.03] hover:bg-white/10 text-xs font-bold text-white/40 hover:text-white transition-colors rounded-md border border-white/[0.03]"
|
||||||
onClick={() => setOpenDropdown(d => d === 'res' ? null : 'res')}
|
onClick={() =>
|
||||||
|
setOpenDropdown((d) => (d === "res" ? null : "res"))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40">
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
</svg>
|
</svg>
|
||||||
{resolution}
|
{resolution}
|
||||||
</button>
|
</button>
|
||||||
{openDropdown === 'res' && (
|
{openDropdown === "res" && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={RESOLUTIONS}
|
items={RESOLUTIONS}
|
||||||
selected={resolution}
|
selected={resolution}
|
||||||
|
|
@ -715,62 +862,44 @@ export default function CinemaStudio({ apiKey, onGenerationComplete, historyItem
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-3 h-full self-end mb-1">
|
||||||
|
|
||||||
{/* Right Group */}
|
|
||||||
<div className="flex items-center gap-2 h-full self-end mb-1">
|
|
||||||
{/* Summary Card (triggers overlay) */}
|
{/* Summary Card (triggers overlay) */}
|
||||||
<button
|
<button
|
||||||
className="flex flex-col items-start justify-center px-4 py-2 bg-[#2a2a2a] rounded-xl border border-white/5 hover:border-white/20 transition-colors text-left flex-1 min-w-[100px] md:min-w-[140px] max-w-[240px] h-[56px] relative group overflow-hidden"
|
className="flex flex-col items-start justify-center px-4 py-1.5 bg-white/[0.03] rounded-md border border-white/[0.03] hover:border-white/20 transition-all text-left flex-1 min-w-[100px] md:min-w-[160px] max-w-[240px] h-[50px] relative group overflow-hidden"
|
||||||
onClick={() => setIsOverlayOpen(true)}
|
onClick={() => setIsOverlayOpen(true)}
|
||||||
>
|
>
|
||||||
<div className="absolute top-2 right-2 w-2 h-2 bg-primary rounded-full shadow-glow-sm" />
|
<div className="absolute top-3 right-3 w-1.5 h-1.5 bg-[#d9ff00] rounded-full shadow-lg shadow-[#d9ff00]/20" />
|
||||||
<span className="text-[10px] font-bold text-white uppercase truncate w-full tracking-wide">
|
<span className="text-[9px] font-bold text-white/30 uppercase truncate w-full tracking-wider group-hover:text-white transition-colors">
|
||||||
{settings.camera}
|
{settings.camera}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-medium text-white/60 truncate w-full">
|
<span className="text-xs font-semibold text-white/70 truncate w-full group-hover:text-[#d9ff00] transition-colors">
|
||||||
{formatSummaryValue()}
|
{formatSummaryValue()}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Generate Button */}
|
{/* Generate Button */}
|
||||||
<button
|
<button
|
||||||
className="h-[56px] px-8 bg-[#d9ff00] text-black rounded-xl font-black text-xs uppercase hover:bg-white transition-colors shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
className="h-[50px] px-8 bg-[#d9ff00] text-black rounded-md font-medium text-sm hover:bg-[#e5ff33] hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center justify-center gap-2 shadow-lg shadow-[#d9ff00]/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
disabled={isGenerating || !settings.prompt.trim()}
|
disabled={isGenerating || !settings.prompt.trim()}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
>
|
>
|
||||||
{isGenerating ? 'SHOOTING...' : 'GENERATE ✨'}
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block text-black">◌</span> SHOOTING...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>SHOOT</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 4. History Sidebar ── */}
|
|
||||||
<div className="fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto transition-all duration-500">
|
|
||||||
<div className="text-[9px] font-bold text-white/40 uppercase tracking-widest mb-2">
|
|
||||||
History
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 w-full px-2">
|
|
||||||
{history.map((entry, idx) => (
|
|
||||||
<div
|
|
||||||
key={entry.timestamp ?? idx}
|
|
||||||
className={`relative group/thumb cursor-pointer rounded-lg overflow-hidden border-2 transition-all duration-300 aspect-square ${idx === activeHistoryIndex ? 'border-[#d9ff00] shadow-glow-sm' : 'border-white/10 hover:border-white/30'}`}
|
|
||||||
onClick={() => loadHistoryItem(entry, idx)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={entry.url}
|
|
||||||
alt={`History item ${idx + 1}`}
|
|
||||||
className="w-full h-full object-cover opacity-80 group-hover/thumb:opacity-100 transition-opacity"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center">
|
|
||||||
<span className="text-[8px] font-bold text-white uppercase">Load</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 5. Camera Controls Overlay ── */}
|
{/* ── Camera Controls Overlay ── */}
|
||||||
<CameraControlsOverlay
|
<CameraControlsOverlay
|
||||||
isOpen={isOverlayOpen}
|
isOpen={isOverlayOpen}
|
||||||
onClose={() => setIsOverlayOpen(false)}
|
onClose={() => setIsOverlayOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async function downloadImage(url, filename) {
|
async function downloadImage(url, filename) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
|
@ -36,7 +35,7 @@ async function downloadImage(url, filename) {
|
||||||
|
|
||||||
// ─── UploadButton (inline picker) ───────────────────────────────────────────
|
// ─── UploadButton (inline picker) ───────────────────────────────────────────
|
||||||
|
|
||||||
function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
function UploadButton({ apiKey, maxImages, onSelect, onClear, initialUrls = [] }) {
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [selectedEntries, setSelectedEntries] = useState([]); // [{url, thumbnail}]
|
const [selectedEntries, setSelectedEntries] = useState([]); // [{url, thumbnail}]
|
||||||
|
|
@ -63,6 +62,28 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
return () => window.removeEventListener("click", handler);
|
return () => window.removeEventListener("click", handler);
|
||||||
}, [panelOpen]);
|
}, [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
|
// When maxImages changes, trim excess selections
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEntries.length > maxImages) {
|
if (selectedEntries.length > maxImages) {
|
||||||
|
|
@ -81,7 +102,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
const urls = entries.map((e) => e.url);
|
const urls = entries.map((e) => e.url);
|
||||||
onSelect({ url: urls[0], urls, thumbnail: entries[0].url });
|
onSelect({ url: urls[0], urls, thumbnail: entries[0].url });
|
||||||
},
|
},
|
||||||
[onSelect]
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileChange = async (e) => {
|
const handleFileChange = async (e) => {
|
||||||
|
|
@ -90,16 +111,20 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
|
|
||||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const tooLarge = files.filter(f => f.size > MAX_IMAGE_SIZE);
|
const tooLarge = files.filter((f) => f.size > MAX_IMAGE_SIZE);
|
||||||
if (tooLarge.length > 0) {
|
if (tooLarge.length > 0) {
|
||||||
alert(`The following images are too large (max 10MB): ${tooLarge.map(f => f.name).join(', ')}`);
|
alert(
|
||||||
|
`The following images are too large (max 10MB): ${tooLarge.map((f) => f.name).join(", ")}`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const toUpload =
|
const toUpload =
|
||||||
maxImages === 1 ? files.slice(0, 1) : files.slice(0, maxImages - selectedEntries.length || 1);
|
maxImages === 1
|
||||||
|
? files.slice(0, 1)
|
||||||
|
: files.slice(0, maxImages - selectedEntries.length || 1);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
toUpload.map(async (file) => {
|
toUpload.map(async (file) => {
|
||||||
|
|
@ -113,7 +138,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
const uploadedUrl = await uploadFile(apiKey, file, (pct) => {
|
const uploadedUrl = await uploadFile(apiKey, file, (pct) => {
|
||||||
setLastUploadProgress(pct);
|
setLastUploadProgress(pct);
|
||||||
setUploadHistory((prev) =>
|
setUploadHistory((prev) =>
|
||||||
prev.map((h) => (h.id === id ? { ...h, progress: pct } : h))
|
prev.map((h) => (h.id === id ? { ...h, progress: pct } : h)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -124,7 +149,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
return { ...h, url: uploadedUrl, progress: 100 };
|
return { ...h, url: uploadedUrl, progress: 100 };
|
||||||
}
|
}
|
||||||
return h;
|
return h;
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-select if there's room
|
// Auto-select if there's room
|
||||||
|
|
@ -142,7 +167,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
setUploadHistory((prev) => prev.filter((h) => h.id !== id));
|
setUploadHistory((prev) => prev.filter((h) => h.id !== id));
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Image upload failed: ${err.message}`);
|
alert(`Image upload failed: ${err.message}`);
|
||||||
|
|
@ -155,7 +180,8 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
const handleCellClick = (entry) => {
|
const handleCellClick = (entry) => {
|
||||||
const selIdx = selectedEntries.findIndex((e) => e.url === entry.url);
|
const selIdx = selectedEntries.findIndex((e) => e.url === entry.url);
|
||||||
const isSelected = selIdx !== -1;
|
const isSelected = selIdx !== -1;
|
||||||
const atMax = maxImages > 1 && !isSelected && selectedEntries.length >= maxImages;
|
const atMax =
|
||||||
|
maxImages > 1 && !isSelected && selectedEntries.length >= maxImages;
|
||||||
if (atMax) return;
|
if (atMax) return;
|
||||||
|
|
||||||
if (maxImages === 1) {
|
if (maxImages === 1) {
|
||||||
|
|
@ -169,7 +195,10 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
next = selectedEntries.filter((_, i) => i !== selIdx);
|
next = selectedEntries.filter((_, i) => i !== selIdx);
|
||||||
if (next.length === 0) onClear?.();
|
if (next.length === 0) onClear?.();
|
||||||
} else {
|
} else {
|
||||||
next = [...selectedEntries, { url: entry.url, localUrl: entry.localUrl }];
|
next = [
|
||||||
|
...selectedEntries,
|
||||||
|
{ url: entry.url, localUrl: entry.localUrl },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
setSelectedEntries(next);
|
setSelectedEntries(next);
|
||||||
}
|
}
|
||||||
|
|
@ -215,25 +244,38 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
badge = (
|
badge = (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
||||||
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
||||||
<span className="text-[8px] font-black text-primary">{lastUploadProgress}%</span>
|
<span className="text-[8px] font-black text-primary">
|
||||||
|
{lastUploadProgress}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (count > 1) {
|
} else if (count > 1) {
|
||||||
badge = (
|
badge = (
|
||||||
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5">
|
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5">
|
||||||
<span className="text-[9px] font-black text-black leading-none">{count}</span>
|
<span className="text-[9px] font-black text-black leading-none">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (canAddMore) {
|
} else if (canAddMore) {
|
||||||
badge = (
|
badge = (
|
||||||
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-white/80 rounded-full flex items-center justify-center px-0.5 border border-primary/60">
|
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-white/80 rounded-full flex items-center justify-center px-0.5 border border-primary/60">
|
||||||
<span className="text-[9px] font-black text-black leading-none">+</span>
|
<span className="text-[9px] font-black text-black leading-none">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
badge = (
|
badge = (
|
||||||
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5">
|
<div className="absolute bottom-0.5 right-0.5 min-w-[16px] h-4 bg-primary rounded-full flex items-center justify-center px-0.5">
|
||||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="black" strokeWidth="4">
|
<svg
|
||||||
|
width="8"
|
||||||
|
height="8"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -242,17 +284,52 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
triggerContent = (
|
triggerContent = (
|
||||||
<>
|
<>
|
||||||
{uploading && hasSelection && (
|
{uploading && hasSelection && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-10">
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 z-30">
|
||||||
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
<div className="w-4 h-4 rounded-full border border-primary/30 border-t-primary animate-spin mb-0.5" />
|
||||||
<span className="text-[8px] font-black text-primary">{lastUploadProgress}%</span>
|
<span className="text-[8px] font-black text-primary">
|
||||||
|
{lastUploadProgress}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mainEntry?.url ? (
|
{count > 1 ? (
|
||||||
<img src={mainEntry.url} alt="" className={`w-full h-full object-cover transition-all duration-300 ${uploading && hasSelection ? 'blur-[2px] scale-110 opacity-60' : 'blur-0 scale-100 opacity-100'}`} />
|
<div className="relative w-full h-full p-1.5 flex items-center justify-center">
|
||||||
|
{/* Bottom Image */}
|
||||||
|
{selectedEntries[1]?.url && (
|
||||||
|
<div className="absolute top-1 left-1 w-6 h-6 rounded-md border border-black/40 overflow-hidden shadow-lg rotate-[-8deg] translate-x-[-1px] translate-y-[-1px]">
|
||||||
|
<img
|
||||||
|
src={selectedEntries[1].url}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Top Image */}
|
||||||
|
{selectedEntries[0]?.url && (
|
||||||
|
<div className="absolute bottom-1 right-1 w-7 h-7 rounded-sm border-[1.5px] border-black/60 overflow-hidden shadow-2xl z-10 rotate-[4deg] translate-x-[1px] translate-y-[1px]">
|
||||||
|
<img
|
||||||
|
src={selectedEntries[0].url}
|
||||||
|
alt=""
|
||||||
|
className={`w-full h-full object-cover transition-all duration-300 ${
|
||||||
|
uploading && hasSelection ? "blur-[2px] opacity-60" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : mainEntry?.url ? (
|
||||||
|
<img
|
||||||
|
src={mainEntry.url}
|
||||||
|
alt=""
|
||||||
|
className={`w-full h-full object-cover transition-all duration-300 ${
|
||||||
|
uploading && hasSelection ? "blur-[2px] scale-110 opacity-60" : "blur-0 scale-100 opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center bg-white/5 animate-pulse">
|
<div className="w-full h-full flex flex-col items-center justify-center bg-white/5 animate-pulse">
|
||||||
<div className="w-4 h-4 rounded-full border border-primary/20 border-t-primary animate-spin mb-0.5" />
|
<div className="w-4 h-4 rounded-full border border-primary/20 border-t-primary animate-spin mb-0.5" />
|
||||||
<span className="text-[8px] font-black text-primary">{lastUploadProgress}%</span>
|
<span className="text-[8px] font-black text-primary">
|
||||||
|
{lastUploadProgress}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!uploading && badge}
|
{!uploading && badge}
|
||||||
|
|
@ -267,11 +344,18 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
className="text-muted group-hover:text-primary transition-colors"
|
className="text-white/40 group-hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
<rect
|
||||||
<polyline points="17 8 12 3 7 8" />
|
x="3"
|
||||||
<line x1="12" y1="3" x2="12" y2="15" />
|
y="3"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +391,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPanelOpen((o) => !o);
|
setPanelOpen((o) => !o);
|
||||||
}}
|
}}
|
||||||
className={`w-10 h-10 shrink-0 rounded-xl border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 hover:bg-white/10 group ${
|
className={`w-10 h-10 shrink-0 rounded-full border transition-all flex items-center justify-center relative overflow-hidden mt-1.5 bg-white/5 hover:bg-white/10 group ${
|
||||||
hasSelection
|
hasSelection
|
||||||
? "border-primary/60 hover:border-primary/40"
|
? "border-primary/60 hover:border-primary/40"
|
||||||
: "border-white/10 hover:border-primary/40"
|
: "border-white/10 hover:border-primary/40"
|
||||||
|
|
@ -321,12 +405,12 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute z-50 bottom-[calc(100%+8px)] left-0 bg-[#111] rounded-3xl p-3 shadow-4xl border border-white/10 w-72"
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-1 pb-3 mb-2 border-b border-white/5">
|
<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">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-[10px] font-bold text-secondary uppercase tracking-widest">
|
<span className="text-xs font-bold text-secondary">
|
||||||
Reference Images
|
Reference Images
|
||||||
</span>
|
</span>
|
||||||
{isMulti && (
|
{isMulti && (
|
||||||
|
|
@ -352,7 +436,7 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
setPanelOpen(false);
|
setPanelOpen(false);
|
||||||
fileInputRef.current?.click();
|
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-xl text-xs font-bold transition-all border border-primary/20"
|
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
|
<svg
|
||||||
width="11"
|
width="11"
|
||||||
|
|
@ -392,9 +476,12 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-2 max-h-56 overflow-y-auto custom-scrollbar pr-0.5">
|
<div className="grid grid-cols-3 gap-2 max-h-56 overflow-y-auto custom-scrollbar pr-0.5">
|
||||||
{uploadHistory.map((entry) => {
|
{uploadHistory.map((entry) => {
|
||||||
const selIdx = selectedEntries.findIndex((e) => e.url === entry.url);
|
const selIdx = selectedEntries.findIndex(
|
||||||
|
(e) => e.url === entry.url,
|
||||||
|
);
|
||||||
const isSelected = selIdx !== -1;
|
const isSelected = selIdx !== -1;
|
||||||
const atMax = isMulti && !isSelected && selectedEntries.length >= maxImages;
|
const atMax =
|
||||||
|
isMulti && !isSelected && selectedEntries.length >= maxImages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -402,7 +489,9 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
title={entry.name}
|
title={entry.name}
|
||||||
onClick={() => entry.url && handleCellClick(entry)}
|
onClick={() => entry.url && handleCellClick(entry)}
|
||||||
className={`relative rounded-xl overflow-hidden border-2 cursor-pointer group/cell aspect-square transition-all ${
|
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"
|
isSelected
|
||||||
|
? "border-primary shadow-glow"
|
||||||
|
: "border-white/10 hover:border-white/30"
|
||||||
} ${atMax ? "opacity-40 cursor-not-allowed" : ""} ${!entry.url ? "cursor-wait" : ""}`}
|
} ${atMax ? "opacity-40 cursor-not-allowed" : ""} ${!entry.url ? "cursor-wait" : ""}`}
|
||||||
>
|
>
|
||||||
{entry.url ? (
|
{entry.url ? (
|
||||||
|
|
@ -414,7 +503,9 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full bg-white/5 flex flex-col items-center justify-center">
|
<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" />
|
<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>
|
<span className="text-[10px] font-black text-primary">
|
||||||
|
{entry.progress}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -427,7 +518,14 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
onClick={(e) => handleRemoveFromHistory(e, entry)}
|
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"
|
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">
|
<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="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -439,9 +537,18 @@ function UploadButton({ apiKey, maxImages, onSelect, onClear }) {
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute top-1 left-1 min-w-[20px] h-5 bg-primary rounded-full flex items-center justify-center px-1">
|
<div className="absolute top-1 left-1 min-w-[20px] h-5 bg-primary rounded-full flex items-center justify-center px-1">
|
||||||
{isMulti ? (
|
{isMulti ? (
|
||||||
<span className="text-[10px] font-black text-black">{selIdx + 1}</span>
|
<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">
|
<svg
|
||||||
|
width="9"
|
||||||
|
height="9"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
@ -482,12 +589,12 @@ function ModelDropdown({ models, selectedModel, onSelect, onClose }) {
|
||||||
const filtered = models.filter(
|
const filtered = models.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
m.id.toLowerCase().includes(search.toLowerCase())
|
m.id.toLowerCase().includes(search.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full max-h-[70vh]">
|
<div className="flex flex-col gap-2 h-full max-h-[60vh]">
|
||||||
<div className="px-2 pb-3 mb-2 border-b border-white/5 shrink-0">
|
<div className="border-b border-white/5 shrink-0">
|
||||||
<div className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-2.5 border border-white/5 focus-within:border-primary/50 transition-colors">
|
<div className="flex items-center gap-3 bg-white/5 rounded-xl px-4 py-2.5 border border-white/5 focus-within:border-primary/50 transition-colors">
|
||||||
<svg
|
<svg
|
||||||
width="14"
|
width="14"
|
||||||
|
|
@ -511,7 +618,7 @@ function ModelDropdown({ models, selectedModel, onSelect, onClose }) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-bold text-secondary uppercase tracking-widest px-3 py-2 shrink-0">
|
<div className="text-xs font-medium text-secondary py-2 shrink-0">
|
||||||
Available models
|
Available models
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 overflow-y-auto custom-scrollbar pr-1 pb-2">
|
<div className="flex flex-col gap-1.5 overflow-y-auto custom-scrollbar pr-1 pb-2">
|
||||||
|
|
@ -523,7 +630,7 @@ function ModelDropdown({ models, selectedModel, onSelect, onClose }) {
|
||||||
onSelect(m);
|
onSelect(m);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
className={`flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all border border-transparent hover:border-white/5 ${
|
className={`flex items-center justify-between p-3.5 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" : ""
|
selectedModel === m.id ? "bg-white/5 border-white/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -535,16 +642,25 @@ function ModelDropdown({ models, selectedModel, onSelect, onClose }) {
|
||||||
: m.family === "effects"
|
: m.family === "effects"
|
||||||
? "bg-purple-500/10 text-purple-400"
|
? "bg-purple-500/10 text-purple-400"
|
||||||
: "bg-primary/10 text-primary"
|
: "bg-primary/10 text-primary"
|
||||||
} border border-white/5 rounded-xl flex items-center justify-center font-black text-sm shadow-inner uppercase`}
|
} border border-white/5 rounded-full flex items-center justify-center font-bold text-xs shadow-inner uppercase`}
|
||||||
>
|
>
|
||||||
{m.name.charAt(0)}
|
{m.name.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs font-bold text-white tracking-tight">{m.name}</span>
|
<span className="text-xs font-bold text-white tracking-tight">
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedModel === m.id && (
|
{selectedModel === m.id && (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="4">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#d9ff00"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
@ -560,7 +676,7 @@ function ModelDropdown({ models, selectedModel, onSelect, onClose }) {
|
||||||
function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
|
function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="text-[10px] font-bold text-muted uppercase tracking-widest px-3 py-2 border-b border-white/5 mb-2">
|
<div className="text-xs font-medium text-muted pb-2 border-b border-white/5 mb-2">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -572,13 +688,20 @@ function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
|
||||||
onSelect(opt);
|
onSelect(opt);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-between p-3.5 hover:bg-white/5 rounded-2xl cursor-pointer transition-all group"
|
className="flex items-center justify-between p-2 hover:bg-white/5 rounded-md cursor-pointer transition-all group"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100">
|
<span className="text-xs font-bold text-white opacity-80 group-hover:opacity-100">
|
||||||
{opt}
|
{opt}
|
||||||
</span>
|
</span>
|
||||||
{selected === opt && (
|
{selected === opt && (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#d9ff00" strokeWidth="4">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#d9ff00"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
<polyline points="20 6 9 17 4 12" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|
@ -591,13 +714,19 @@ function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
|
||||||
|
|
||||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ImageStudio({ apiKey, onGenerationComplete, historyItems }) {
|
export default function ImageStudio({
|
||||||
|
apiKey,
|
||||||
|
onGenerationComplete,
|
||||||
|
historyItems,
|
||||||
|
}) {
|
||||||
|
const PERSIST_KEY = "hg_image_studio_persistent";
|
||||||
|
|
||||||
// ── Model / mode state ──────────────────────────────────────────────────
|
// ── Model / mode state ──────────────────────────────────────────────────
|
||||||
const [imageMode, setImageMode] = useState(false); // false=t2i, true=i2i
|
const [imageMode, setImageMode] = useState(false); // false=t2i, true=i2i
|
||||||
const [selectedModelId, setSelectedModelId] = useState(t2iModels[0].id);
|
const [selectedModelId, setSelectedModelId] = useState(t2iModels[0].id);
|
||||||
const [selectedModelName, setSelectedModelName] = useState(t2iModels[0].name);
|
const [selectedModelName, setSelectedModelName] = useState(t2iModels[0].name);
|
||||||
const [selectedAr, setSelectedAr] = useState(
|
const [selectedAr, setSelectedAr] = useState(
|
||||||
t2iModels[0].inputs?.aspect_ratio?.default || "1:1"
|
t2iModels[0].inputs?.aspect_ratio?.default || "1:1",
|
||||||
);
|
);
|
||||||
const [selectedQuality, setSelectedQuality] = useState(() => {
|
const [selectedQuality, setSelectedQuality] = useState(() => {
|
||||||
const resolutions = getResolutionsForModel(t2iModels[0].id);
|
const resolutions = getResolutionsForModel(t2iModels[0].id);
|
||||||
|
|
@ -613,6 +742,7 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(null); // 'model' | 'ar' | 'quality' | null
|
const [dropdownOpen, setDropdownOpen] = useState(null); // 'model' | 'ar' | 'quality' | null
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [generateError, setGenerateError] = useState(null);
|
const [generateError, setGenerateError] = useState(null);
|
||||||
|
const [fullscreenUrl, setFullscreenUrl] = useState(null);
|
||||||
|
|
||||||
// ── Canvas / history state ──────────────────────────────────────────────
|
// ── Canvas / history state ──────────────────────────────────────────────
|
||||||
const [currentImageUrl, setCurrentImageUrl] = useState(null);
|
const [currentImageUrl, setCurrentImageUrl] = useState(null);
|
||||||
|
|
@ -639,14 +769,59 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
return () => window.removeEventListener("click", handler);
|
return () => window.removeEventListener("click", handler);
|
||||||
}, [dropdownOpen]);
|
}, [dropdownOpen]);
|
||||||
|
|
||||||
// ── Textarea auto-resize ─────────────────────────────────────────────────
|
// ── Persistence: Load ────────────────────────────────────────────────────
|
||||||
const handleTextareaInput = () => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current;
|
try {
|
||||||
if (!el) return;
|
const stored = localStorage.getItem(PERSIST_KEY);
|
||||||
el.style.height = "auto";
|
if (stored) {
|
||||||
const maxHeight = window.innerWidth < 768 ? 150 : 250;
|
const data = JSON.parse(stored);
|
||||||
el.style.height = Math.min(el.scrollHeight, maxHeight) + "px";
|
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.maxImages) setMaxImages(data.maxImages);
|
||||||
|
if (data.prompt) setPrompt(data.prompt);
|
||||||
|
if (data.uploadedImageUrls) setUploadedImageUrls(data.uploadedImageUrls);
|
||||||
|
if (data.localHistory) setLocalHistory(data.localHistory);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to load ImageStudio persistence:", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Persistence: Save ────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const state = {
|
||||||
|
imageMode,
|
||||||
|
selectedModelId,
|
||||||
|
selectedModelName,
|
||||||
|
selectedAr,
|
||||||
|
selectedQuality,
|
||||||
|
maxImages,
|
||||||
|
prompt,
|
||||||
|
uploadedImageUrls,
|
||||||
|
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,
|
||||||
|
maxImages,
|
||||||
|
prompt,
|
||||||
|
uploadedImageUrls,
|
||||||
|
localHistory,
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Derived: current model lists & helpers ───────────────────────────────
|
// ── Derived: current model lists & helpers ───────────────────────────────
|
||||||
const currentModels = imageMode ? i2iModels : t2iModels;
|
const currentModels = imageMode ? i2iModels : t2iModels;
|
||||||
|
|
@ -661,6 +836,15 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
: getQualityFieldForModel(selectedModelId);
|
: getQualityFieldForModel(selectedModelId);
|
||||||
const showQualityBtn = currentResolutions.length > 0;
|
const showQualityBtn = currentResolutions.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 ──────────────────────────────────────────────
|
// ── Upload picker callbacks ──────────────────────────────────────────────
|
||||||
const handleUploadSelect = useCallback(
|
const handleUploadSelect = useCallback(
|
||||||
({ url, urls }) => {
|
({ url, urls }) => {
|
||||||
|
|
@ -679,7 +863,7 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
setMaxImages(getMaxImagesForI2IModel(firstI2I.id));
|
setMaxImages(getMaxImagesForI2IModel(firstI2I.id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[imageMode]
|
[imageMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUploadClear = useCallback(() => {
|
const handleUploadClear = useCallback(() => {
|
||||||
|
|
@ -719,11 +903,10 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
setActiveHistoryIdx(0);
|
setActiveHistoryIdx(0);
|
||||||
setCurrentImageUrl(entry.url);
|
setCurrentImageUrl(entry.url);
|
||||||
},
|
},
|
||||||
[historyItems]
|
[historyItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── View state: 'prompt' | 'canvas' ─────────────────────────────────────
|
// ── View state ─────────────────────────────────────
|
||||||
const showCanvas = currentImageUrl !== null;
|
|
||||||
|
|
||||||
const resetToPrompt = () => {
|
const resetToPrompt = () => {
|
||||||
setCurrentImageUrl(null);
|
setCurrentImageUrl(null);
|
||||||
|
|
@ -822,37 +1005,42 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-y-auto custom-scrollbar overflow-x-hidden">
|
<div className="w-full h-full flex flex-col items-center justify-center bg-app-bg relative p-4 md:p-6 overflow-hidden">
|
||||||
|
|
||||||
{/* ── CANVAS VIEW ─────────────────────────────────────────────────── */}
|
{/* ── CENTRAL GALLERY AREA ── */}
|
||||||
{showCanvas && (
|
<div className="flex-1 w-full max-w-7xl mx-auto overflow-y-auto custom-scrollbar pb-40 lg:pb-32 px-2">
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center p-4 min-[800px]:p-16 z-10">
|
{history.length > 0 ? (
|
||||||
{/* History sidebar */}
|
<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.length > 0 && (
|
|
||||||
<div className="fixed right-0 top-0 h-full w-20 md:w-24 bg-black/60 backdrop-blur-xl border-l border-white/5 z-50 flex flex-col items-center py-4 gap-3 overflow-y-auto">
|
|
||||||
<div className="text-[9px] font-bold text-muted uppercase tracking-widest mb-2">
|
|
||||||
History
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 w-full px-2">
|
|
||||||
{history.map((entry, idx) => (
|
{history.map((entry, idx) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id || idx}
|
key={entry.id || idx}
|
||||||
onClick={() => {
|
className="relative group rounded-2xl overflow-hidden border border-white/10 bg-[#0a0a0a] shadow-xl hover:border-primary/50 transition-all duration-300 flex flex-col"
|
||||||
setCurrentImageUrl(entry.url);
|
|
||||||
setActiveHistoryIdx(idx);
|
|
||||||
}}
|
|
||||||
className={`relative group/thumb cursor-pointer rounded-xl overflow-hidden border-2 transition-all duration-300 ${
|
|
||||||
idx === activeHistoryIdx
|
|
||||||
? "border-primary shadow-glow"
|
|
||||||
: "border-white/10 hover:border-white/30"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={entry.url}
|
src={entry.url}
|
||||||
alt={entry.prompt?.substring(0, 30) || "Generated"}
|
alt={entry.prompt?.substring(0, 30) || "Generated image"}
|
||||||
className="w-full aspect-square object-cover"
|
className="w-full aspect-square object-cover bg-black/40 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={() => setFullscreenUrl(entry.url)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/thumb:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
|
||||||
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Download"
|
title="Download"
|
||||||
|
|
@ -860,79 +1048,35 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
downloadImage(entry.url, `muapi-${entry.id || idx}.jpg`);
|
downloadImage(entry.url, `muapi-${entry.id || idx}.jpg`);
|
||||||
}}
|
}}
|
||||||
className="p-1.5 bg-primary rounded-lg text-black hover:scale-110 transition-transform"
|
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="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
<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" />
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="flex flex-col items-center justify-center h-full animate-fade-in-up transition-all duration-700 min-h-[50vh]">
|
||||||
|
<div className="mb-12 relative group">
|
||||||
{/* Main image */}
|
<div className="absolute inset-0 bg-primary/10 blur-[120px] rounded-full opacity-30 group-hover:opacity-60 transition-opacity duration-1000" />
|
||||||
<div className="relative group">
|
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-white/[0.02] rounded-[2rem] flex items-center justify-center border border-white/[0.05] overflow-hidden backdrop-blur-sm">
|
||||||
<img
|
<div className="w-16 h-16 bg-primary/5 rounded-2xl flex items-center justify-center border border-primary/10 relative z-10 transition-transform duration-500 group-hover:scale-110">
|
||||||
src={currentImageUrl}
|
|
||||||
alt={history[activeHistoryIdx]?.prompt || "Generated image"}
|
|
||||||
className="max-h-[60vh] max-w-[80vw] rounded-3xl shadow-3xl border border-white/10 interactive-glow object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Canvas controls */}
|
|
||||||
<div className="mt-6 flex gap-3 justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={generating}
|
|
||||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
↻ Regenerate
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const entry = history[activeHistoryIdx];
|
|
||||||
downloadImage(currentImageUrl, `muapi-${entry?.id || "image"}.jpg`);
|
|
||||||
}}
|
|
||||||
className="bg-primary text-black px-6 py-2.5 rounded-2xl text-xs font-bold transition-all shadow-glow active:scale-95"
|
|
||||||
>
|
|
||||||
↓ Download
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetToPrompt}
|
|
||||||
className="bg-white/10 hover:bg-white/20 px-6 py-2.5 rounded-2xl text-xs font-bold transition-all border border-white/5 backdrop-blur-lg text-white"
|
|
||||||
>
|
|
||||||
+ New
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── PROMPT VIEW ─────────────────────────────────────────────────── */}
|
|
||||||
{!showCanvas && (
|
|
||||||
<>
|
|
||||||
{/* Hero */}
|
|
||||||
<div className="flex flex-col items-center mb-10 md:mb-20 animate-fade-in-up transition-all duration-700">
|
|
||||||
<div className="mb-10 relative group">
|
|
||||||
<div className="absolute inset-0 bg-primary/20 blur-[100px] rounded-full opacity-40 group-hover:opacity-70 transition-opacity duration-1000" />
|
|
||||||
<div className="relative w-24 h-24 md:w-32 md:h-32 bg-teal-900/40 rounded-3xl flex items-center justify-center border border-white/5 overflow-hidden">
|
|
||||||
<svg
|
|
||||||
width="80"
|
|
||||||
height="80"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
className="text-primary opacity-20 absolute -right-4 -bottom-4"
|
|
||||||
>
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
||||||
</svg>
|
|
||||||
<div className="w-16 h-16 bg-primary/10 rounded-2xl flex items-center justify-center border border-primary/20 shadow-glow relative z-10">
|
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
|
|
@ -940,36 +1084,46 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
className="text-primary"
|
className="text-primary opacity-80"
|
||||||
>
|
>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
<polyline points="21 15 16 10 5 21" />
|
<polyline points="21 15 16 10 5 21" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-4 right-4 text-primary animate-pulse">✨</div>
|
<div className="absolute top-4 right-4 text-[10px] text-primary/40 animate-pulse">
|
||||||
|
✨
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl sm:text-4xl md:text-7xl font-black text-white tracking-widest uppercase mb-4 selection:bg-primary selection:text-black text-center px-4">
|
</div>
|
||||||
Image Studio
|
<h1 className="text-3xl sm:text-5xl md:text-6xl font-extrabold text-white tracking-tight mb-4 text-center px-4">
|
||||||
|
<span className="text-white/40 font-medium">START CREATING WITH</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-white">IMAGE STUDIO</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-secondary text-sm font-medium tracking-wide opacity-60">
|
<p className="text-white/40 text-sm md:text-base font-medium tracking-wide text-center max-w-lg leading-relaxed">
|
||||||
Transform images with AI — upscale, stylize, animate and more
|
Describe a scene, character, mood, or style — and watch it come to life
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Prompt bar */}
|
{/* ── BOTTOM PROMPT BAR ── */}
|
||||||
<div className="w-full max-w-4xl relative z-40 animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
|
<div
|
||||||
<div className="w-full bg-[#111]/90 backdrop-blur-xl border border-white/10 rounded-[1.5rem] md:rounded-[2.5rem] p-3 md:p-5 flex flex-col gap-3 md:gap-5 shadow-3xl">
|
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-[#0a0a0a]/80 backdrop-blur-3xl rounded-md border border-white/10 p-4 flex flex-col gap-2 shadow-2xl">
|
||||||
{/* Top row: upload picker + textarea */}
|
{/* Top row: upload picker + textarea */}
|
||||||
<div className="flex items-start gap-5 px-2">
|
<div className="flex items-center gap-2">
|
||||||
<UploadButton
|
<UploadButton
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
maxImages={maxImages}
|
maxImages={maxImages}
|
||||||
onSelect={handleUploadSelect}
|
onSelect={handleUploadSelect}
|
||||||
onClear={handleUploadClear}
|
onClear={handleUploadClear}
|
||||||
|
initialUrls={uploadedImageUrls}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
|
|
@ -977,16 +1131,15 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
onInput={handleTextareaInput}
|
onInput={handleTextareaInput}
|
||||||
placeholder={placeholderText}
|
placeholder={placeholderText}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 bg-transparent border-none text-white text-base md:text-xl placeholder:text-muted focus:outline-none resize-none pt-2.5 leading-relaxed min-h-[40px] max-h-[150px] md:max-h-[250px] overflow-y-auto custom-scrollbar"
|
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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bottom row: controls + generate */}
|
{/* Bottom row: controls + generate */}
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 px-2 pt-4 border-t border-white/5 relative">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-2 border-t border-white/[0.03] relative">
|
||||||
|
|
||||||
{/* Left controls */}
|
{/* Left controls */}
|
||||||
<div className="flex items-center gap-1.5 md:gap-2.5 relative flex-wrap pb-1 md:pb-0">
|
<div className="flex items-center gap-2 relative flex-wrap pb-1 md:pb-0">
|
||||||
|
|
||||||
{/* Model button */}
|
{/* Model button */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
|
@ -995,12 +1148,12 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropdownOpen((o) => (o === "model" ? null : "model"));
|
setDropdownOpen((o) => (o === "model" ? null : "model"));
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap"
|
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div className="w-5 h-5 bg-primary rounded-md flex items-center justify-center shadow-lg shadow-primary/20">
|
<div className="w-4 h-4 bg-[#d9ff00] rounded flex items-center justify-center">
|
||||||
<span className="text-[10px] font-black text-black">G</span>
|
<span className="text-[9px] font-bold text-black uppercase">G</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-bold text-white group-hover:text-primary transition-colors">
|
<span className="text-xs font-semibold text-white/70 group-hover:text-[#d9ff00] transition-colors">
|
||||||
{selectedModelName}
|
{selectedModelName}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -1010,7 +1163,7 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="4"
|
strokeWidth="4"
|
||||||
className="opacity-20 group-hover:opacity-100 transition-opacity"
|
className="opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
>
|
>
|
||||||
<path d="M6 9l6 6 6-6" />
|
<path d="M6 9l6 6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -1020,7 +1173,7 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute bottom-[calc(100%+8px)] left-0 z-50 bg-[#111] rounded-3xl p-3 shadow-4xl border border-white/10 w-[calc(100vw-3rem)] max-w-xs"
|
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-lg p-3 shadow-2xl border border-white/[0.05] w-[calc(100vw-3rem)] max-w-xs"
|
||||||
>
|
>
|
||||||
<ModelDropdown
|
<ModelDropdown
|
||||||
models={currentModels}
|
models={currentModels}
|
||||||
|
|
@ -1040,39 +1193,20 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropdownOpen((o) => (o === "ar" ? null : "ar"));
|
setDropdownOpen((o) => (o === "ar" ? null : "ar"));
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap"
|
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-md transition-all border border-white/[0.03] group whitespace-nowrap"
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
className="opacity-60 text-secondary"
|
|
||||||
>
|
>
|
||||||
|
<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" />
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs font-bold text-white group-hover:text-primary transition-colors">
|
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#d9ff00] transition-colors">
|
||||||
{selectedAr}
|
{selectedAr}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
|
||||||
width="10"
|
|
||||||
height="10"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
className="opacity-20 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{dropdownOpen === "ar" && (
|
{dropdownOpen === "ar" && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute bottom-[calc(100%+8px)] left-0 z-50 bg-[#111] rounded-3xl p-3 shadow-4xl border border-white/10 max-w-[240px]"
|
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-md p-3 shadow-2xl border border-white/10 min-w-[160px]"
|
||||||
>
|
>
|
||||||
<SimpleDropdown
|
<SimpleDropdown
|
||||||
title="Aspect Ratio"
|
title="Aspect Ratio"
|
||||||
|
|
@ -1094,39 +1228,20 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropdownOpen((o) => (o === "quality" ? null : "quality"));
|
setDropdownOpen((o) => (o === "quality" ? null : "quality"));
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 md:gap-2.5 px-3 md:px-4 py-2 md:py-2.5 bg-white/5 hover:bg-white/10 rounded-xl md:rounded-2xl transition-all border border-white/5 group whitespace-nowrap"
|
className="flex items-center gap-2 px-3 py-2 bg-white/[0.03] hover:bg-white/[0.06] rounded-xl transition-all border border-white/[0.03] group whitespace-nowrap"
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
className="opacity-60 text-secondary"
|
|
||||||
>
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="opacity-40 text-white">
|
||||||
<path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z" />
|
<path d="M6 2L3 6v15a2 2 0 002 2h14a2 2 0 002-2V6l-3-4H6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs font-bold text-white group-hover:text-primary transition-colors">
|
<span className="text-[11px] font-semibold text-white/70 group-hover:text-[#d9ff00] transition-colors">
|
||||||
{selectedQuality || currentResolutions[0]}
|
{selectedQuality || currentResolutions[0]}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
|
||||||
width="10"
|
|
||||||
height="10"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
className="opacity-20 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{dropdownOpen === "quality" && (
|
{dropdownOpen === "quality" && (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute bottom-[calc(100%+8px)] left-0 z-50 bg-[#111] rounded-3xl p-3 shadow-4xl border border-white/10 max-w-[200px]"
|
className="absolute bottom-[calc(100%+12px)] left-0 z-50 bg-[#0a0a0a] rounded-[1.5rem] p-3 shadow-2xl border border-white/[0.05] min-w-[160px]"
|
||||||
>
|
>
|
||||||
<SimpleDropdown
|
<SimpleDropdown
|
||||||
title="Resolution"
|
title="Resolution"
|
||||||
|
|
@ -1146,7 +1261,7 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
className="bg-primary text-black px-6 md:px-8 py-3 md:py-3.5 rounded-xl md:rounded-[1.5rem] font-black text-sm md:text-base hover:shadow-glow hover:scale-105 active:scale-95 transition-all flex items-center justify-center gap-2.5 w-full sm:w-auto shadow-lg disabled:opacity-70 disabled:cursor-not-allowed disabled:scale-100"
|
className="bg-[#d9ff00] text-black px-4 py-2 rounded-md font-medium text-sm hover:bg-[#e5ff33] 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-[#d9ff00]/10 disabled:opacity-50 disabled:cursor-not-allowed z-10"
|
||||||
>
|
>
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1156,13 +1271,41 @@ export default function ImageStudio({ apiKey, onGenerationComplete, historyItems
|
||||||
) : generateError ? (
|
) : generateError ? (
|
||||||
`Error: ${generateError}`
|
`Error: ${generateError}`
|
||||||
) : (
|
) : (
|
||||||
"Generate ✨"
|
<>
|
||||||
|
<span>Generate</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -4,3 +4,4 @@ export { default as ImageStudio } from './components/ImageStudio';
|
||||||
export { default as VideoStudio } from './components/VideoStudio';
|
export { default as VideoStudio } from './components/VideoStudio';
|
||||||
export { default as LipSyncStudio } from './components/LipSyncStudio';
|
export { default as LipSyncStudio } from './components/LipSyncStudio';
|
||||||
export { default as CinemaStudio } from './components/CinemaStudio';
|
export { default as CinemaStudio } from './components/CinemaStudio';
|
||||||
|
export * from './muapi';
|
||||||
|
|
|
||||||
|
|
@ -170,3 +170,16 @@ export function uploadFile(apiKey, file, onProgress) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserBalance(apiKey) {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/v1/account/balance`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch balance: ${response.status} - ${errText.slice(0, 100)}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue