"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, removeVideo, 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(null); const [showControls, setShowControls] = React.useState(true); const [isMobile, setIsMobile] = React.useState(false); React.useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); }, []); 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)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any 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) { // eslint-disable-line @typescript-eslint/no-explicit-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)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any 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) { // eslint-disable-line @typescript-eslint/no-explicit-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 (
{/* Header with Clear All */}

{gallery.length} Creations

Your library of generated images

{gallery.length > 0 && ( )}
{/* Videos Section - Show generated videos */} {videos.length > 0 && (

{videos.length} Generated Video{videos.length > 1 ? 's' : ''}

{videos.map((vid) => ( ))}
)} {/* Gallery Grid */}
{/* Skeleton Loading State */} {isGenerating && ( <> {Array.from({ length: settings.imageCount || 4 }).map((_, i) => (
))} )} {gallery.map((img, i) => ( setSelectedIndex(i)} > {img.prompt} {/* Overlay Gradient */}
{/* Provider Badge */}
{img.provider === 'meta' ? 'META AI' : 'WHISK'}
{/* Delete button (Floating) */} {/* Caption - Glass Style */}

{img.prompt}

))}
{/* Lightbox Modal - Split Panel Design */} {selectedIndex !== null && selectedImage && ( setSelectedIndex(null)} > {/* Top Controls Bar */}
{/* Split Panel Container */} e.stopPropagation()} onPanEnd={(_, info) => { // Swipe Up specific (check velocity or offset) // Negative Y is UP. if (!showControls && info.offset.y < -50) { setShowControls(true); } }} > {/* Left: Image Container (Full size) */}
{/* Repositioned Arrows (relative to image container) */} {selectedIndex > 0 && ( )} {selectedIndex < gallery.length - 1 && ( )} {/* Swipe Up Hint Handle (Only when controls are hidden) */} {!showControls && (
{/* Optional: Add a chevron up or just the line. User asked for "hint handle", implying likely just the pill shape or similar to iOS home bar but specifically for swiping up content */} )}
{/* Right: Controls Panel (Retractable) */} {showControls && ( { if (isMobile && info.offset.y > 100) { setShowControls(false); } }} className="w-full md:w-[400px] flex flex-col bg-card/80 dark:bg-black/80 border-l border-border backdrop-blur-2xl shadow-left-premium z-[130] absolute bottom-0 inset-x-0 md:relative md:inset-auto md:h-full h-[70vh] rounded-t-[3rem] md:rounded-none overflow-hidden touch-none" > {/* Drag Handle (Mobile) */}
{/* Provider Badge */} {selectedImage.provider && (
{selectedImage.provider}
)} {/* Prompt Section (Editable) */}

Original Prompt

{editPromptValue !== selectedImage.prompt && ( Modified )}