apix/components/Gallery.tsx
Khoa.vo 2a4bf8b58b
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run
feat: updates before deployment
2026-01-06 13:26:11 +07:00

471 lines
25 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 '';
// If it's already a URL, use it directly
if (data.startsWith('http://') || data.startsWith('https://') || data.startsWith('data:')) {
return data;
}
// Otherwise, treat as base64
return `data:image/png;base64,${data}`;
};
export function Gallery() {
const { gallery, clearGallery, removeFromGallery, setPrompt, addVideo, addToGallery, settings, videos, removeVideo, isGenerating } = useStore();
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const [videoModalOpen, setVideoModalOpen] = React.useState(false);
const [videoSource, setVideoSource] = React.useState<{ data: string, prompt: string } | null>(null);
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [editSource, setEditSource] = React.useState<{ data: string, prompt: string } | null>(null);
const openVideoModal = (img: { data: string, prompt: string }) => {
setVideoSource(img);
setVideoModalOpen(true);
};
const openEditModal = (img: { data: string, prompt: string }) => {
setEditSource(img);
setEditModalOpen(true);
};
const handleGenerateVideo = async (prompt: string) => {
if (!videoSource) return;
if (!settings.whiskCookies) {
alert("Please set your Whisk Cookies in Settings first!");
throw new Error("Missing Whisk cookies");
}
const res = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
imageBase64: videoSource.data,
// imageGenerationId: (videoSource as any).id, // REMOVE: "id" is a local DB ID (e.g. 1), not a Whisk Media ID.
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: videoSource.data, // Use source image as thumb
createdAt: Date.now()
});
// Success notification
setTimeout(() => {
alert('🎬 Video generation complete!\n\nYour video has been saved. Go to the "Uploads" page and select the "Videos" tab to view it.');
}, 100);
} else {
console.error(data.error);
// Show user-friendly error messages for Google safety policies
let errorMessage = data.error;
if (data.error?.includes('NCII')) {
errorMessage = '🚫 Content Policy: Video blocked by Google\'s NCII (Non-Consensual Intimate Imagery) 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. Try using a different image.';
} else if (data.error?.includes('safety') || data.error?.includes('SAFETY')) {
errorMessage = '⚠️ Content Policy: Video blocked by Google\'s safety filters. Try a different source image.';
} else if (data.error?.includes('401') || data.error?.includes('UNAUTHENTICATED')) {
errorMessage = '🔐 Authentication Error: Your Whisk (Google) cookies have expired. Please go to Settings and update them.';
}
alert(errorMessage);
throw new Error(data.error);
}
};
const handleRemix = async (prompt: string, options: { keepSubject: boolean; keepScene: boolean; keepStyle: boolean }) => {
if (!editSource) return;
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 = () => {
if (window.confirm("Delete all " + gallery.length + " images?")) {
clearGallery();
}
};
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>
{/* Overlay */}
<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 mb-2">{img.prompt}</p>
<div className="flex gap-2 justify-end pointer-events-auto">
<button
onClick={(e) => {
e.stopPropagation();
setPrompt(img.prompt);
navigator.clipboard.writeText(img.prompt);
// Optional: Toast feedback could go here
}}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Use Prompt"
>
<Copy className="h-4 w-4" />
</button>
<a
href={getImageSrc(img.data)}
download={"generated-" + i + "-" + Date.now() + ".png"}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Download"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-4 w-4" />
</a>
{/* Remix button - only for Whisk (base64) images for now */}
{(!img.provider || img.provider === 'whisk') && (
<button
onClick={(e) => {
e.stopPropagation();
openEditModal(img);
}}
className="p-2 bg-gradient-to-br from-amber-500/80 to-purple-600/80 hover:from-amber-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-purple-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Remix this image"
>
<Wand2 className="h-4 w-4" />
</button>
)}
{/* Video button - only for 16:9 images AND Whisk provider (base64) */}
{img.aspectRatio === '16:9' && (!img.provider || img.provider === 'whisk') ? (
<button
onClick={(e) => {
e.stopPropagation();
openVideoModal(img);
}}
className="p-2 bg-gradient-to-br from-blue-500/80 to-purple-600/80 hover:from-blue-500 hover:to-purple-600 rounded-full text-white shadow-lg shadow-blue-900/20 backdrop-blur-md transition-all hover:scale-105 border border-white/10"
title="Generate Video"
>
<Film className="h-4 w-4" />
</button>
) : (
<button
disabled
className="p-2 bg-gray-500/30 rounded-full text-white/30 cursor-not-allowed border border-white/5"
title="Video generation requires 16:9 images and Whisk provider"
>
<Film className="h-4 w-4" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setSelectedIndex(i); }}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded-full text-white backdrop-blur-md transition-colors"
title="Maximize"
>
<Maximize2 className="h-4 w-4" />
</button>
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Lightbox Modal */}
<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/90 backdrop-blur-sm p-4 md:p-8"
onClick={() => setSelectedIndex(null)}
>
{/* Close Button */}
<button
className="absolute top-4 right-4 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={() => setSelectedIndex(null)}
>
<X className="h-6 w-6" />
</button>
{/* Navigation Buttons */}
{selectedIndex > 0 && (
<button
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
>
<ChevronLeft className="h-8 w-8" />
</button>
)}
{selectedIndex < gallery.length - 1 && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }}
>
<ChevronRight className="h-8 w-8" />
</button>
)}
{/* Image Container */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative max-w-7xl max-h-full flex flex-col items-center"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<img
src={getImageSrc(selectedImage.data)}
alt={selectedImage.prompt}
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
/>
<div className="mt-4 flex flex-col items-center gap-2 max-w-2xl text-center">
<p className="text-white/90 text-sm md:text-base font-medium line-clamp-2">
{selectedImage.prompt}
</p>
<div className="flex gap-3">
<a
href={getImageSrc(selectedImage.data)}
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full font-medium transition-colors"
>
<Download className="h-4 w-4" />
Download Current
</a>
{(!selectedImage.provider || selectedImage.provider === 'whisk') && (
<button
onClick={() => {
if (selectedImage) openVideoModal(selectedImage);
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full font-medium transition-colors"
>
<Film className="h-4 w-4" />
Generate Video
</button>
)}
<button
onClick={() => {
setPrompt(selectedImage.prompt);
navigator.clipboard.writeText(selectedImage.prompt);
setSelectedIndex(null); // Close lightbox? Or keep open? User said "reuse", likely wants to edit.
// Let's close it so they can see the input updating.
}}
className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full font-medium transition-colors"
>
<Copy className="h-4 w-4" />
Use Prompt
</button>
</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 >
);
}