| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421 |
- "use client";
- import { useState, useEffect, useCallback } from "react";
- import { useRouter } from "next/navigation";
- import { Header } from "@/components/layout";
- import { AppLayout } from "@/components/layout";
- import { Button } from "@/components/ui/button";
- import { Card, CardContent } from "@/components/ui/card";
- import { Badge } from "@/components/ui/badge";
- import { apiClient, type JobDetailsResponse } from "@/lib/api";
- import {
- Loader2,
- Download,
- RefreshCw,
- Calendar,
- Image as ImageIcon,
- Maximize2,
- ArrowUp,
- } from "lucide-react";
- import { downloadAuthenticatedImage } from "@/lib/utils";
- interface GalleryImage {
- jobId: string;
- filename: string;
- url: string;
- thumbnailUrl: string;
- prompt?: string;
- negativePrompt?: string;
- width?: number;
- height?: number;
- steps?: number;
- cfgScale?: number;
- seed?: string;
- model?: string;
- createdAt: string;
- status: string;
- }
- interface ImageModalProps {
- image: GalleryImage | null;
- isOpen: boolean;
- onClose: () => void;
- }
- function ImageModal({ image, isOpen, onClose }: ImageModalProps) {
- if (!isOpen || !image) return null;
- const handleBackdropClick = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget) {
- onClose();
- }
- };
- return (
- <div
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
- onClick={handleBackdropClick}
- >
- <div className="relative max-w-[95vw] max-h-[95vh] flex flex-col w-full">
- {/* Image container with responsive sizing */}
- <div className="flex items-center justify-center bg-muted/50 p-4 rounded-lg flex-1 min-h-0">
- <div className="relative w-full h-full flex items-center justify-center">
- <img
- src={image.url}
- alt="Generated image"
- className="max-w-full max-h-full object-contain rounded-lg shadow-lg"
- style={{
- width: 'auto',
- height: 'auto',
- maxWidth: '100%',
- maxHeight: '100%'
- }}
- />
- </div>
- </div>
- </div>
- </div>
- );
- }
- function GalleryGrid() {
- const router = useRouter();
- const [images, setImages] = useState<GalleryImage[]>([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [sortBy, setSortBy] = useState<"newest" | "oldest">("newest");
- const loadGalleryImages = useCallback(async () => {
- try {
- setLoading(true);
- setError(null);
- console.log("Gallery: Loading images...");
- // Get queue status to find all completed jobs
- const queueStatus = await apiClient.getQueueStatus();
- console.log("Gallery: Queue status:", queueStatus);
- const completedJobs = queueStatus.jobs.filter(
- (job) => job.status === "completed",
- );
- console.log("Gallery: Completed jobs:", completedJobs);
- const galleryImages: GalleryImage[] = [];
- // Fetch individual job details to get output information
- for (const job of completedJobs) {
- const jobId = job.request_id || job.id || "";
- if (!jobId) continue;
- try {
- // Get detailed job information including outputs
- console.log(`Gallery: Fetching details for job ${jobId}`);
- const jobDetails: JobDetailsResponse =
- await apiClient.getJobStatus(jobId);
- console.log(`Gallery: Job ${jobId} details:`, jobDetails);
- // API response has outputs nested in job object
- if (
- jobDetails.job &&
- jobDetails.job.outputs &&
- jobDetails.job.outputs.length > 0
- ) {
- for (const output of jobDetails.job.outputs) {
- const filename = output.filename;
- const url = apiClient.getImageUrl(jobId, filename);
- // Create thumbnail URL (we'll use the same URL but let the browser handle scaling)
- const thumbnailUrl = url;
- galleryImages.push({
- jobId,
- filename,
- url,
- thumbnailUrl,
- prompt: jobDetails.job.prompt || "", // Get prompt from job details
- negativePrompt: "", // Job info doesn't include negative prompt
- width: undefined, // Job info doesn't include request details
- height: undefined, // Job info doesn't include request details
- steps: undefined, // Job info doesn't include request details
- cfgScale: undefined, // Job info doesn't include request details
- seed: undefined, // Job info doesn't include request details
- model: "Unknown", // Job info doesn't include request details
- createdAt:
- jobDetails.job.created_at || new Date().toISOString(),
- status: jobDetails.job.status,
- });
- }
- }
- } catch (err) {
- console.warn(`Failed to fetch details for job ${jobId}:`, err);
- // Continue with other jobs even if one fails
- }
- }
- // Sort images
- galleryImages.sort((a, b) => {
- const dateA = new Date(a.createdAt).getTime();
- const dateB = new Date(b.createdAt).getTime();
- return sortBy === "newest" ? dateB - dateA : dateA - dateB;
- });
- console.log("Gallery: Final images array:", galleryImages);
- setImages(galleryImages);
- } catch (err) {
- console.error("Gallery: Error loading images:", err);
- setError(
- err instanceof Error ? err.message : "Failed to load gallery images",
- );
- } finally {
- setLoading(false);
- }
- }, [sortBy]);
- useEffect(() => {
- loadGalleryImages();
- }, [sortBy, loadGalleryImages]);
- const handleImageClick = (image: GalleryImage) => {
- setSelectedImage(image);
- setIsModalOpen(true);
- };
- const handleModalClose = () => {
- setIsModalOpen(false);
- setSelectedImage(null);
- };
- const handleDownload = (image: GalleryImage, e: React.MouseEvent) => {
- e.stopPropagation();
- const authToken = localStorage.getItem("auth_token");
- const unixUser = localStorage.getItem("unix_user");
- downloadAuthenticatedImage(
- image.url,
- `gallery-${image.jobId}-${image.filename}`,
- authToken || undefined,
- unixUser || undefined,
- );
- };
- const handleUpscale = (image: GalleryImage, e: React.MouseEvent) => {
- e.stopPropagation();
- // Navigate to upscaler page with image URL as query parameter
- router.push(`/upscaler?imageUrl=${encodeURIComponent(image.url)}`);
- };
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString("en-US", {
- year: "numeric",
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
- };
- if (loading) {
- return (
- <AppLayout>
- <Header title="Gallery" description="Browse your generated images" />
- <div className="container mx-auto p-6">
- <div className="flex items-center justify-center h-96">
- <div className="flex items-center gap-2">
- <Loader2 className="h-6 w-6 animate-spin" />
- <span>Loading gallery...</span>
- </div>
- </div>
- </div>
- </AppLayout>
- );
- }
- if (error) {
- return (
- <AppLayout>
- <Header title="Gallery" description="Browse your generated images" />
- <div className="container mx-auto p-6">
- <div className="flex flex-col items-center justify-center h-96 gap-4">
- <div className="text-destructive text-center">
- <p className="text-lg font-medium">Error loading gallery</p>
- <p className="text-sm">{error}</p>
- </div>
- <Button onClick={loadGalleryImages} variant="outline">
- <RefreshCw className="h-4 w-4 mr-2" />
- Try Again
- </Button>
- </div>
- </div>
- </AppLayout>
- );
- }
- return (
- <AppLayout>
- <Header title="Gallery" description="Browse your generated images" />
- <div className="container mx-auto p-6">
- {/* Controls */}
- <div className="flex items-center justify-between mb-6">
- <div className="flex items-center gap-4">
- <h2 className="text-2xl font-bold">
- {images.length} {images.length === 1 ? "Image" : "Images"}
- </h2>
- <Button onClick={loadGalleryImages} variant="outline" size="sm">
- <RefreshCw className="h-4 w-4 mr-2" />
- Refresh
- </Button>
- </div>
- <div className="flex items-center gap-2">
- <span className="text-sm text-muted-foreground">Sort by:</span>
- <Button
- variant={sortBy === "newest" ? "default" : "outline"}
- size="sm"
- onClick={() => setSortBy("newest")}
- >
- Newest
- </Button>
- <Button
- variant={sortBy === "oldest" ? "default" : "outline"}
- size="sm"
- onClick={() => setSortBy("oldest")}
- >
- Oldest
- </Button>
- </div>
- </div>
- {/* Gallery Grid */}
- {images.length === 0 ? (
- <div className="flex flex-col items-center justify-center h-96 border-2 border-dashed border-border rounded-lg">
- <ImageIcon className="h-12 w-12 text-muted-foreground mb-4" />
- <h3 className="text-lg font-medium text-muted-foreground mb-2">
- No images found
- </h3>
- <p className="text-sm text-muted-foreground text-center max-w-md">
- Generate some images first using the Text to Image, Image to
- Image, or Inpainting tools.
- </p>
- </div>
- ) : (
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
- {images.map((image: GalleryImage, index: number) => (
- <Card
- key={`${image.jobId}-${image.filename}-${index}`}
- className="group cursor-pointer overflow-hidden hover:shadow-lg transition-all duration-200 hover:scale-105"
- onClick={() => handleImageClick(image)}
- >
- <CardContent className="p-0">
- <div className="relative aspect-square">
- {/* Thumbnail with hover effect */}
- <div className="relative w-full h-full overflow-hidden">
- <img
- src={image.thumbnailUrl}
- alt={`Generated image ${index + 1}`}
- className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
- loading="lazy"
- />
-
- </div>
- {/* Date badge */}
- <div className="absolute top-2 left-2">
- <Badge
- variant="secondary"
- className="text-xs bg-black/70 text-white border-none"
- >
- {formatDate(image.createdAt)}
- </Badge>
- </div>
- {/* Model indicator */}
- {image.model && image.model !== "Unknown" && (
- <div className="absolute bottom-2 left-2">
- <Badge
- variant="outline"
- className="text-xs max-w-20 truncate bg-black/70 text-white border-white/20"
- >
- {image.model}
- </Badge>
- </div>
- )}
- </div>
- {/* Image info */}
- <div className="p-2 bg-background">
- <div className="flex items-center justify-between text-xs text-muted-foreground">
- <span className="truncate">
- {image.width && image.height
- ? `${image.width}×${image.height}`
- : "Unknown size"}
- </span>
- <div className="flex items-center gap-1">
- <Calendar className="h-3 w-3" />
- <span className="text-xs">
- {new Date(image.createdAt).toLocaleDateString()}
- </span>
- </div>
- </div>
- {image.prompt && (
- <p className="mt-1 text-xs text-muted-foreground line-clamp-2">
- {image.prompt}
- </p>
- )}
- {/* Action buttons */}
- <div className="grid grid-cols-3 gap-1 mt-2">
- <Button
- size="sm"
- variant="outline"
- className="h-7 text-xs px-1"
- onClick={(e: React.MouseEvent) => {
- e.stopPropagation();
- handleImageClick(image);
- }}
- title="View full size"
- >
- <Maximize2 className="h-3 w-3" />
- </Button>
- <Button
- size="sm"
- variant="outline"
- className="h-7 text-xs px-1"
- onClick={(e: React.MouseEvent) => handleDownload(image, e)}
- title="Download image"
- >
- <Download className="h-3 w-3" />
- </Button>
- <Button
- size="sm"
- variant="outline"
- className="h-7 text-xs px-1"
- onClick={(e: React.MouseEvent) => handleUpscale(image, e)}
- title="Upscale image"
- >
- <ArrowUp className="h-3 w-3" />
- </Button>
- </div>
- </div>
- </CardContent>
- </Card>
- ))}
- </div>
- )}
- {/* Image Modal */}
- <ImageModal
- image={selectedImage}
- isOpen={isModalOpen}
- onClose={handleModalClose}
- />
- </div>
- </AppLayout>
- );
- }
- export default function GalleryPage() {
- return <GalleryGrid />;
- }
|