751 lines
39 KiB
TypeScript
751 lines
39 KiB
TypeScript
"use client";
|
|
|
|
import React from 'react';
|
|
import { useStore } from '@/lib/store';
|
|
import { cn } from "@/lib/utils";
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Download, Maximize2, Sparkles, Trash2, X, ChevronLeft, ChevronRight, Copy, Film, Wand2 } from 'lucide-react';
|
|
import { VideoPromptModal } from './VideoPromptModal';
|
|
import { EditPromptModal } from './EditPromptModal';
|
|
|
|
// Helper function to get proper image src (handles URLs vs base64)
|
|
const getImageSrc = (data: string): string => {
|
|
if (!data) return '';
|
|
const cleanData = data.trim();
|
|
// If it's already a URL, use it directly
|
|
if (cleanData.indexOf('http') === 0 || cleanData.indexOf('data:') === 0) {
|
|
return cleanData;
|
|
}
|
|
|
|
// Otherwise, treat as base64 (don't warn - base64 often contains 'http' as random characters)
|
|
return `data:image/png;base64,${cleanData}`;
|
|
};
|
|
|
|
export function Gallery() {
|
|
const {
|
|
gallery, loadGallery, addToGallery, removeFromGallery, clearGallery,
|
|
isGenerating,
|
|
settings,
|
|
videos, addVideo,
|
|
setPrompt
|
|
} = useStore();
|
|
const [videoModalOpen, setVideoModalOpen] = React.useState(false);
|
|
const [videoSource, setVideoSource] = React.useState<{ data: string, prompt: string, provider?: string } | null>(null);
|
|
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
|
const [editSource, setEditSource] = React.useState<{ data: string, prompt: string, provider?: 'whisk' | 'meta' } | null>(null);
|
|
const [editPromptValue, setEditPromptValue] = React.useState('');
|
|
const [videoPromptValue, setVideoPromptValue] = React.useState('');
|
|
const [useSourceImage, setUseSourceImage] = React.useState(true);
|
|
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (selectedIndex !== null && gallery[selectedIndex]) {
|
|
setEditSource(gallery[selectedIndex]);
|
|
setEditPromptValue(gallery[selectedIndex].prompt || '');
|
|
setVideoPromptValue('');
|
|
setUseSourceImage(true);
|
|
}
|
|
}, [selectedIndex, gallery]);
|
|
|
|
React.useEffect(() => {
|
|
loadGallery();
|
|
}, []); // Only load on mount
|
|
|
|
const openVideoModal = (img: { data: string, prompt: string, provider?: string }) => {
|
|
setVideoSource(img);
|
|
setVideoModalOpen(true);
|
|
};
|
|
|
|
const openEditModal = (img: { data: string, prompt: string }) => {
|
|
setEditSource(img);
|
|
setEditModalOpen(true);
|
|
};
|
|
|
|
const [isGeneratingMetaVideo, setIsGeneratingMetaVideo] = React.useState(false); // Kept for UI state compatibility
|
|
const [isGeneratingWhiskVideo, setIsGeneratingWhiskVideo] = React.useState(false);
|
|
|
|
// Handle Meta AI video generation (text-to-video via Kadabra)
|
|
const handleGenerateMetaVideo = async (img: { data: string; prompt: string }, customPrompt?: string) => {
|
|
if (!settings.metaCookies && !settings.facebookCookies) {
|
|
alert("Please set your Meta AI (or Facebook) Cookies in Settings first!");
|
|
return;
|
|
}
|
|
|
|
setIsGeneratingMetaVideo(true);
|
|
|
|
try {
|
|
console.log("[Gallery] Starting Meta AI video generation...");
|
|
|
|
// Create a descriptive prompt that includes the original image context + animation
|
|
const originalPrompt = img.prompt || "";
|
|
const animationDescription = customPrompt || "natural movement";
|
|
|
|
// Combine original image description with animation instruction
|
|
const promptText = originalPrompt
|
|
? `Create a video of: ${originalPrompt}. Animation: ${animationDescription}`
|
|
: `Create an animated video with: ${animationDescription}`;
|
|
|
|
console.log("[Gallery] Meta video prompt:", promptText);
|
|
|
|
// Merge cookies safely
|
|
let mergedCookies = settings.metaCookies;
|
|
try {
|
|
const safeParse = (str: string) => {
|
|
if (!str || str === "undefined" || str === "null") return [];
|
|
try { return JSON.parse(str); } catch { return []; }
|
|
};
|
|
const m = safeParse(settings.metaCookies);
|
|
const f = safeParse(settings.facebookCookies);
|
|
if (Array.isArray(m) || Array.isArray(f)) {
|
|
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
|
|
}
|
|
} catch (e) { console.error("Cookie merge failed", e); }
|
|
|
|
const res = await fetch('/api/meta/video', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: promptText,
|
|
cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies)
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
console.log("[Gallery] Meta video response:", data);
|
|
|
|
if (data.success && data.videos?.length > 0) {
|
|
for (const video of data.videos) {
|
|
addVideo({
|
|
id: crypto.randomUUID(),
|
|
url: video.url,
|
|
prompt: promptText,
|
|
thumbnail: img.data,
|
|
createdAt: Date.now()
|
|
});
|
|
}
|
|
alert('🎬 Video generation complete! Scroll up to see your video.');
|
|
setVideoModalOpen(false);
|
|
} else {
|
|
throw new Error(data.error || 'No videos generated');
|
|
}
|
|
} catch (error: any) {
|
|
console.error("[Gallery] Meta video error:", error);
|
|
let errorMessage = error.message || 'Video generation failed';
|
|
if (errorMessage.includes('401') || errorMessage.includes('cookies') || errorMessage.includes('expired')) {
|
|
errorMessage = '🔐 Your Meta AI cookies have expired. Please go to Settings and update them.';
|
|
}
|
|
alert(errorMessage);
|
|
} finally {
|
|
setIsGeneratingMetaVideo(false);
|
|
}
|
|
};
|
|
|
|
const handleGenerateVideo = async (prompt: string, sourceOverride?: { data: string; prompt: string; provider?: string; aspectRatio?: string }) => {
|
|
const activeSource = sourceOverride || videoSource;
|
|
if (!activeSource) return;
|
|
|
|
// Route to Meta AI video for meta provider
|
|
if (activeSource.provider === 'meta') {
|
|
await handleGenerateMetaVideo(activeSource, prompt);
|
|
return;
|
|
}
|
|
|
|
if (!settings.whiskCookies) {
|
|
alert("Please set your Whisk Cookies in Settings first!");
|
|
throw new Error("Missing Whisk cookies");
|
|
}
|
|
|
|
setIsGeneratingWhiskVideo(true);
|
|
|
|
try {
|
|
const res = await fetch('/api/video/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: prompt,
|
|
imageBase64: activeSource.data,
|
|
cookies: settings.whiskCookies
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
console.log("[Gallery] Video API response:", data);
|
|
if (data.success) {
|
|
console.log("[Gallery] Adding video to store:", { id: data.id, url: data.url?.substring(0, 50) });
|
|
addVideo({
|
|
id: data.id,
|
|
url: data.url,
|
|
prompt: prompt,
|
|
thumbnail: activeSource.data,
|
|
createdAt: Date.now()
|
|
});
|
|
alert('🎬 Video generation complete!\n\nYour video has been saved. Go to the "Uploads" page and select the "Videos" tab to view it.');
|
|
} else {
|
|
console.error(data.error);
|
|
let errorMessage = data.error;
|
|
if (data.error?.includes('NCII')) {
|
|
errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII protection. Please try with a different source image.';
|
|
} else if (data.error?.includes('PROMINENT_PEOPLE') || data.error?.includes('prominent')) {
|
|
errorMessage = '🚫 Content Policy: Video blocked because the image contains a recognizable person.';
|
|
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
|
|
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters.';
|
|
} else if (data.error?.includes('401') || data.error?.includes('UNAUTHENTICATED')) {
|
|
errorMessage = '🔐 Authentication Error: Your Whisk cookies have expired. Please update in Settings.';
|
|
} else if (data.error?.includes('429') || data.error?.includes('RESOURCE_EXHAUSTED')) {
|
|
errorMessage = '⏱️ Rate Limit: Too many requests. Please wait a few minutes and try again.';
|
|
}
|
|
alert(errorMessage);
|
|
throw new Error(data.error);
|
|
}
|
|
} finally {
|
|
setIsGeneratingWhiskVideo(false);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
const handleRemix = async (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => {
|
|
if (!editSource) return;
|
|
|
|
// Meta AI Remix Flow (Prompt Edit Only)
|
|
if (editSource.provider === 'meta') {
|
|
if (!settings.metaCookies && !settings.facebookCookies) {
|
|
alert("Please set your Meta AI (or Facebook) Cookies in Settings first!");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Merge cookies safely
|
|
let mergedCookies = settings.metaCookies;
|
|
try {
|
|
const safeParse = (str: string) => {
|
|
if (!str || str === "undefined" || str === "null") return [];
|
|
try { return JSON.parse(str); } catch { return []; }
|
|
};
|
|
const m = safeParse(settings.metaCookies);
|
|
const f = safeParse(settings.facebookCookies);
|
|
if (Array.isArray(m) || Array.isArray(f)) {
|
|
mergedCookies = [...(Array.isArray(m) ? m : []), ...(Array.isArray(f) ? f : [])] as any;
|
|
}
|
|
} catch (e) { console.error("Cookie merge failed", e); }
|
|
|
|
const res = await fetch('/api/meta/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: prompt,
|
|
cookies: typeof mergedCookies === 'string' ? mergedCookies : JSON.stringify(mergedCookies),
|
|
imageCount: 4
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.error) throw new Error(data.error);
|
|
|
|
if (data.success && data.images?.length > 0) {
|
|
// Add new images to gallery
|
|
const newImages = data.images.map((img: any) => ({
|
|
id: crypto.randomUUID(),
|
|
data: img.data, // Base64
|
|
prompt: prompt,
|
|
createdAt: Date.now(),
|
|
width: 1024,
|
|
height: 1024,
|
|
aspectRatio: settings.aspectRatio,
|
|
provider: 'meta'
|
|
}));
|
|
|
|
// Add to store
|
|
newImages.forEach(addToGallery);
|
|
|
|
alert('✨ Remix complete! New images added to gallery.');
|
|
setEditModalOpen(false);
|
|
} else {
|
|
throw new Error('No images generated');
|
|
}
|
|
} catch (e: any) {
|
|
console.error("Meta Remix failed", e);
|
|
alert("Remix failed: " + e.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Whisk Remix Flow (Reference Injection)
|
|
if (!settings.whiskCookies) {
|
|
alert("Please set your Whisk Cookies in Settings first!");
|
|
throw new Error("Missing Whisk cookies");
|
|
}
|
|
|
|
// First upload the current image as a reference
|
|
const uploadRes = await fetch('/api/references/upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
imageBase64: `data:image/png;base64,${editSource.data}`,
|
|
mimeType: 'image/png',
|
|
category: 'subject', // Use as subject reference
|
|
cookies: settings.whiskCookies
|
|
})
|
|
});
|
|
|
|
const uploadData = await uploadRes.json();
|
|
if (!uploadData.id) {
|
|
throw new Error("Failed to upload reference image");
|
|
}
|
|
|
|
// Build refs based on consistency options
|
|
const refs: { subject?: string[]; scene?: string[]; style?: string[] } = {};
|
|
if (options.keepSubject) refs.subject = [uploadData.id];
|
|
if (options.keepScene) refs.scene = [uploadData.id];
|
|
if (options.keepStyle) refs.style = [uploadData.id];
|
|
|
|
// Generate new image with references
|
|
const res = await fetch('/api/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: prompt,
|
|
aspectRatio: settings.aspectRatio,
|
|
imageCount: 1, // Generate one remix at a time
|
|
cookies: settings.whiskCookies,
|
|
refs: refs
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
console.error(data.error);
|
|
alert("Remix generation failed: " + data.error);
|
|
throw new Error(data.error);
|
|
}
|
|
|
|
if (data.images) {
|
|
for (const img of data.images) {
|
|
await addToGallery({
|
|
data: img.data,
|
|
prompt: img.prompt,
|
|
aspectRatio: img.aspectRatio,
|
|
createdAt: Date.now()
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (selectedIndex === null) return;
|
|
if (e.key === 'Escape') setSelectedIndex(null);
|
|
if (e.key === 'ArrowLeft') setSelectedIndex(prev => (prev !== null && prev > 0 ? prev - 1 : prev));
|
|
if (e.key === 'ArrowRight') setSelectedIndex(prev => (prev !== null && prev < gallery.length - 1 ? prev + 1 : prev));
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [selectedIndex, gallery.length]);
|
|
|
|
if (gallery.length === 0) {
|
|
return null; // Or return generic empty state if controlled by parent, but parent checks length usually
|
|
}
|
|
|
|
const handleClearAll = async () => {
|
|
const count = gallery.length;
|
|
if (!window.confirm(`Delete all ${count} images? This will reset the gallery database.`)) return;
|
|
|
|
try {
|
|
console.log("[Gallery] Hard clearing...");
|
|
|
|
// 1. Clear Zustand Store visual state immediate
|
|
clearGallery();
|
|
|
|
// 2. Nuclear Option: Delete the entire database file
|
|
console.log("[Gallery] Deleting IndexedDB...");
|
|
const req = window.indexedDB.deleteDatabase('kv-pix-db');
|
|
|
|
req.onsuccess = () => {
|
|
console.log("✅ DB Deleted successfully");
|
|
// Clear localStorage persistence too just in case
|
|
localStorage.removeItem('kv-pix-storage');
|
|
window.location.reload();
|
|
};
|
|
|
|
req.onerror = (e) => {
|
|
console.error("❌ Failed to delete DB", e);
|
|
alert("Failed to delete database. Browser might be blocking it.");
|
|
window.location.reload();
|
|
};
|
|
|
|
req.onblocked = () => {
|
|
console.warn("⚠️ DB Delete blocked - reloading to free locks");
|
|
window.location.reload();
|
|
};
|
|
|
|
} catch (e) {
|
|
console.error("[Gallery] Delete error:", e);
|
|
alert("❌ Failed to delete: " + String(e));
|
|
}
|
|
};
|
|
|
|
const selectedImage = selectedIndex !== null ? gallery[selectedIndex] : null;
|
|
|
|
return (
|
|
<div className="pb-32">
|
|
{/* Header with Clear All */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-xl font-semibold">{gallery.length} Generated Images</h2>
|
|
</div>
|
|
<button
|
|
onClick={handleClearAll}
|
|
className="flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span>Clear All</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Videos Section - Show generated videos */}
|
|
{videos.length > 0 && (
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Film className="h-5 w-5 text-blue-500" />
|
|
<h3 className="text-lg font-semibold">{videos.length} Generated Video{videos.length > 1 ? 's' : ''}</h3>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{videos.map((vid) => (
|
|
<motion.div
|
|
key={vid.id}
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="group relative aspect-video rounded-xl overflow-hidden bg-black border border-white/10 shadow-lg"
|
|
>
|
|
<video
|
|
src={vid.url}
|
|
poster={vid.thumbnail ? `data:image/png;base64,${vid.thumbnail}` : undefined}
|
|
className="w-full h-full object-cover"
|
|
controls
|
|
preload="metadata"
|
|
/>
|
|
<button
|
|
onClick={() => removeVideo(vid.id)}
|
|
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-destructive/80 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
|
|
title="Delete video"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
<div className="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
|
<p className="text-white text-xs line-clamp-1">{vid.prompt}</p>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gallery Grid */}
|
|
<div className="columns-1 sm:columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4">
|
|
{/* Skeleton Loading State */}
|
|
{isGenerating && (
|
|
<>
|
|
{Array.from({ length: settings.imageCount || 4 }).map((_, i) => (
|
|
<div key={`skeleton-${i}`} className="break-inside-avoid rounded-xl overflow-hidden bg-white/5 border border-white/5 shadow-sm mb-4 relative aspect-[2/3] animate-pulse">
|
|
<div className="absolute inset-0 bg-gradient-to-t from-white/10 to-transparent" />
|
|
<div className="absolute bottom-4 left-4 right-4 h-4 bg-white/20 rounded w-3/4" />
|
|
<div className="absolute top-2 left-2 w-12 h-4 bg-white/20 rounded" />
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<AnimatePresence mode='popLayout'>
|
|
{gallery.map((img, i) => (
|
|
<motion.div
|
|
key={img.id || `video-${i}`}
|
|
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
className="group relative break-inside-avoid rounded-xl overflow-hidden bg-card border shadow-sm"
|
|
>
|
|
<img
|
|
src={getImageSrc(img.data)}
|
|
alt={img.prompt}
|
|
className="w-full h-auto object-cover transition-transform group-hover:scale-105 cursor-pointer"
|
|
onClick={() => setSelectedIndex(i)}
|
|
loading="lazy"
|
|
/>
|
|
|
|
{/* Provider Tag */}
|
|
{img.provider && (
|
|
<div className={cn(
|
|
"absolute top-2 left-2 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider text-white shadow-sm backdrop-blur-md border border-white/10 z-10",
|
|
img.provider === 'meta' ? "bg-blue-500/80" :
|
|
img.provider === 'grok' ? "bg-yellow-500/80 text-black" :
|
|
"bg-amber-500/80"
|
|
)}>
|
|
{img.provider}
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete button - Top right */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); if (img.id) removeFromGallery(img.id); }}
|
|
|
|
className="absolute top-2 right-2 p-1.5 bg-black/50 hover:bg-destructive/80 rounded-full text-white opacity-0 group-hover:opacity-100 transition-all"
|
|
title="Delete"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
|
|
{/* Hover Overlay - Simplified: just show prompt */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3 pointer-events-none">
|
|
<p className="text-white text-xs line-clamp-2">{img.prompt}</p>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Lightbox Modal - Split Panel Design */}
|
|
<AnimatePresence>
|
|
{selectedIndex !== null && selectedImage && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 backdrop-blur-md p-4 md:p-6"
|
|
onClick={() => setSelectedIndex(null)}
|
|
>
|
|
{/* Close Button */}
|
|
<button
|
|
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
|
|
onClick={() => setSelectedIndex(null)}
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
|
|
{/* Navigation Buttons */}
|
|
{selectedIndex > 0 && (
|
|
<button
|
|
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
|
|
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
|
|
>
|
|
<ChevronLeft className="h-6 w-6 md:h-8 md:w-8" />
|
|
</button>
|
|
)}
|
|
{selectedIndex < gallery.length - 1 && (
|
|
<button
|
|
className="absolute left-[calc(50%-2rem)] md:left-[calc(50%+8rem)] top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
|
|
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }}
|
|
>
|
|
<ChevronRight className="h-6 w-6 md:h-8 md:w-8" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Split Panel Container */}
|
|
<motion.div
|
|
initial={{ scale: 0.95, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.95, opacity: 0 }}
|
|
className="relative w-full max-w-6xl max-h-[90vh] flex flex-col md:flex-row gap-4 md:gap-6 overflow-hidden"
|
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
>
|
|
{/* Left: Image */}
|
|
<div className="flex-1 flex items-center justify-center min-h-0">
|
|
<img
|
|
src={getImageSrc(selectedImage.data)}
|
|
alt={selectedImage.prompt}
|
|
className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
|
|
/>
|
|
</div>
|
|
|
|
{/* Right: Controls Panel */}
|
|
<div className="w-full md:w-80 lg:w-96 flex flex-col gap-4 bg-white/5 rounded-xl p-4 md:p-5 border border-white/10 backdrop-blur-sm max-h-[40vh] md:max-h-[85vh] overflow-y-auto">
|
|
{/* Provider Badge */}
|
|
{selectedImage.provider && (
|
|
<div className={cn(
|
|
"self-start px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider",
|
|
selectedImage.provider === 'meta' ? "bg-blue-500/20 text-blue-300 border border-blue-500/30" :
|
|
selectedImage.provider === 'grok' ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" :
|
|
"bg-amber-500/20 text-amber-300 border border-amber-500/30"
|
|
)}>
|
|
{selectedImage.provider}
|
|
</div>
|
|
)}
|
|
|
|
{/* Prompt Section (Editable) */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Prompt</h3>
|
|
{editPromptValue !== selectedImage.prompt && (
|
|
<span className="text-[10px] text-amber-400 font-medium animate-pulse">Modified</span>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
value={editPromptValue}
|
|
onChange={(e) => setEditPromptValue(e.target.value)}
|
|
className="w-full h-24 bg-black/20 border border-white/10 rounded-lg p-3 text-sm text-white resize-none focus:ring-1 focus:ring-amber-500/30 outline-none placeholder:text-white/20"
|
|
placeholder="Enter prompt..."
|
|
/>
|
|
<div className="flex gap-2">
|
|
{(!selectedImage.provider || selectedImage.provider === 'whisk' || selectedImage.provider === 'meta') && (
|
|
<button
|
|
onClick={() => openEditModal({ ...selectedImage, prompt: editPromptValue })}
|
|
className="flex-1 py-2 bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 rounded-lg text-xs font-medium text-white transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<Wand2 className="h-3 w-3" />
|
|
<span>Remix</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(editPromptValue);
|
|
alert("Prompt copied!");
|
|
}}
|
|
className={cn(
|
|
"px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors",
|
|
(!selectedImage.provider || selectedImage.provider === 'whisk') ? "" : "flex-1"
|
|
)}
|
|
title="Copy Prompt"
|
|
>
|
|
<Copy className="h-4 w-4 mx-auto" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t border-white/10" />
|
|
|
|
{/* Video Generation Section */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider flex items-center gap-2">
|
|
<Film className="h-3 w-3" />
|
|
Animate
|
|
</h3>
|
|
</div>
|
|
<textarea
|
|
value={videoPromptValue}
|
|
onChange={(e) => setVideoPromptValue(e.target.value)}
|
|
placeholder="Describe movement (e.g. natural movement, zoom in)..."
|
|
className="w-full h-20 bg-black/20 border border-white/10 rounded-lg p-3 text-sm text-white resize-none focus:ring-1 focus:ring-purple-500/50 outline-none placeholder:text-white/30"
|
|
/>
|
|
{(() => {
|
|
const isGenerating = isGeneratingMetaVideo || isGeneratingWhiskVideo;
|
|
const isWhisk = !selectedImage.provider || selectedImage.provider === 'whisk';
|
|
const isMeta = selectedImage.provider === 'meta';
|
|
const is16by9 = selectedImage.aspectRatio === '16:9';
|
|
// Only Whisk with 16:9 can generate video - Meta video API not available
|
|
const canGenerate = isWhisk && is16by9;
|
|
|
|
return (
|
|
<button
|
|
onClick={() => handleGenerateVideo(videoPromptValue, selectedImage)}
|
|
disabled={isGenerating || !canGenerate}
|
|
className={cn(
|
|
"relative z-10 w-full py-2 rounded-lg text-xs font-medium text-white transition-all flex items-center justify-center gap-2",
|
|
isGenerating
|
|
? "bg-gray-600 cursor-wait"
|
|
: !canGenerate
|
|
? "bg-gray-600/50 cursor-not-allowed opacity-60"
|
|
: "bg-purple-600 hover:bg-purple-500"
|
|
)}
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
<span>Generating Video...</span>
|
|
</>
|
|
) : isMeta ? (
|
|
<>
|
|
<Film className="h-3.5 w-3.5 opacity-50" />
|
|
<span>Video coming soon</span>
|
|
</>
|
|
) : !canGenerate ? (
|
|
<>
|
|
<Film className="h-3.5 w-3.5 opacity-50" />
|
|
<span>Video requires 16:9 ratio</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Film className="h-3.5 w-3.5" />
|
|
<span>Generate Video</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="border-t border-white/10" />
|
|
|
|
{/* Other Actions */}
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Other Actions</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<a
|
|
href={getImageSrc(selectedImage.data)}
|
|
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
|
|
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-white/80 text-xs font-medium transition-colors"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
<span>Download</span>
|
|
</a>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setPrompt(selectedImage.prompt);
|
|
setSelectedIndex(null);
|
|
}}
|
|
className="flex items-center justify-center gap-2 px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-white/80 text-xs font-medium transition-colors"
|
|
>
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
<span>Use Prompt</span>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (selectedImage.id) {
|
|
removeFromGallery(selectedImage.id);
|
|
setSelectedIndex(null);
|
|
}
|
|
}}
|
|
className="flex items-center justify-center gap-2 w-full px-3 py-2 bg-red-500/10 hover:bg-red-500/20 rounded-lg text-red-400 text-xs font-medium transition-colors border border-red-500/20"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
<span>Delete Image</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Image Info */}
|
|
<div className="mt-auto pt-3 border-t border-white/10 text-xs text-white/40 space-y-1">
|
|
{selectedImage.aspectRatio && (
|
|
<p>Aspect Ratio: {selectedImage.aspectRatio}</p>
|
|
)}
|
|
<p>Image {selectedIndex + 1} of {gallery.length}</p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
{/* Video Modal */}
|
|
<VideoPromptModal
|
|
isOpen={videoModalOpen}
|
|
onClose={() => setVideoModalOpen(false)}
|
|
image={videoSource}
|
|
onGenerate={handleGenerateVideo}
|
|
/>
|
|
{/* Edit/Remix Modal */}
|
|
<EditPromptModal
|
|
isOpen={editModalOpen}
|
|
onClose={() => setEditModalOpen(false)}
|
|
image={editSource}
|
|
onGenerate={handleRemix}
|
|
/>
|
|
</div >
|
|
);
|
|
}
|